]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Replace Presidio with an open-source PII detector (#100) v0.5.0
authorStefan Gasser <redacted>
Mon, 22 Jun 2026 10:29:05 +0000 (12:29 +0200)
committerGitHub <redacted>
Mon, 22 Jun 2026 10:29:05 +0000 (12:29 +0200)
Replace the Microsoft Presidio analyzer with a self-hosted open-source PII detector (Python/FastAPI, detector/) shipped in the all-in-one image: multilingual GLiNER NER plus a deterministic regex/checksum layer for structured identifiers.

- Add VAT_CODE (EU VAT, checksum-validated); member-state prefixes only, case-insensitive and overlap-safe so a label or word can't hide a valid number.
- /api/mask: detect secrets before PII so a connection string isn't partly masked as an email (matches the provider routes).
- Rename presidio_url to detector_url; language-agnostic detection; CPU-only torch; detector CI (ruff/pyright/pytest).
- Bump version to 0.5.0.

53 files changed:
.conductor/settings.toml
.dockerignore [new file with mode: 0644]
.github/workflows/ci.yml
.github/workflows/release.yml
AGENTS.md
CONTRIBUTING.md
README.md
benchmarks/pii-accuracy/taxonomy.ts
benchmarks/pii-accuracy/test-data/core.yaml
benchmarks/pii-accuracy/test-data/precision.yaml
config.example.yaml
detector/.gitignore [new file with mode: 0644]
detector/detector/__init__.py [new file with mode: 0644]
detector/detector/app.py [new file with mode: 0644]
detector/detector/deterministic.py [new file with mode: 0644]
detector/detector/entities.py [new file with mode: 0644]
detector/detector/gliner_layer.py [new file with mode: 0644]
detector/detector/merge.py [new file with mode: 0644]
detector/pyproject.toml [new file with mode: 0644]
detector/tests/test_analyze.py [new file with mode: 0644]
detector/tests/test_deterministic.py [new file with mode: 0644]
detector/tests/test_gliner_layer.py [new file with mode: 0644]
detector/tests/test_merge.py [new file with mode: 0644]
docker-compose.yml
docker/Dockerfile
docker/presidio/generate-configs.py [deleted file]
docker/presidio/languages.yaml [deleted file]
docker/supervisord.conf
docs/api-reference/mask.mdx
docs/api-reference/status.mdx
docs/concepts/pii-detection.mdx
docs/configuration/overview.mdx
docs/configuration/pii-detection.mdx
docs/installation.mdx
docs/introduction.mdx
docs/quickstart.mdx
docs/use-cases/chat.mdx
docs/use-cases/coding-tools.mdx
package.json
src/config.test.ts
src/config.ts
src/constants/languages.ts
src/index.ts
src/pii/detect.test.ts
src/pii/detect.ts
src/routes/api.test.ts
src/routes/api.ts
src/routes/codex.test.ts
src/routes/health.test.ts
src/routes/health.ts
src/routes/info.ts
src/services/language-detector.ts
src/services/pii.ts

index 0b9f1d1256d826ca69a1797d07aa159ea740f839..eda2c10ed16d352fdf7942cc1d93663e6f0bda84 100644 (file)
@@ -9,7 +9,7 @@ fi
 mkdir -p data
 """
 run = """
-docker compose -p pasteguard-dev --profile dev up presidio -d
+docker compose -p pasteguard-dev --profile dev up detector -d
 CONDUCTOR_PORT=${CONDUCTOR_PORT:-3000} bun run dev
 """
 archive = "true"
diff --git a/.dockerignore b/.dockerignore
new file mode 100644 (file)
index 0000000..9647604
--- /dev/null
@@ -0,0 +1,14 @@
+.git
+node_modules
+dist
+data
+config.yaml
+.env
+.gstack
+.context
+.conductor
+**/__pycache__
+**/*.pyc
+detector/.venv
+detector/tests
+*.egg-info
index 6458f5b9c058880e756d7493de39206d69d07bec..383d74cd10d92d627e84728a4d4c55f1c82a064a 100644 (file)
@@ -33,6 +33,42 @@ jobs:
       - name: Run tests
         run: bun test
 
+  detector:
+    name: Detector Lint, Types & Tests
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
+
+      - name: Install detector
+        working-directory: detector
+        run: |
+          python -m venv .venv
+          .venv/bin/pip install --upgrade pip
+          # CPU-only torch so the gliner dependency does not pull the CUDA build.
+          .venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
+          .venv/bin/pip install -e ".[dev]"
+
+      - name: Lint
+        working-directory: detector
+        run: .venv/bin/ruff check .
+
+      - name: Format check
+        working-directory: detector
+        run: .venv/bin/ruff format . --check
+
+      - name: Type check
+        working-directory: detector
+        run: .venv/bin/pyright
+
+      - name: Run tests
+        working-directory: detector
+        run: .venv/bin/python -m pytest -q
+
   docker-build:
     name: Docker Build Test
     runs-on: ubuntu-latest
@@ -40,5 +76,6 @@ jobs:
     steps:
       - uses: actions/checkout@v4
 
+      # Build the all-in-one image users actually run (proxy + detector).
       - name: Test Docker build
         run: docker build -f docker/Dockerfile -t pasteguard:test .
index 8e8fcaf5d8434959fd5813585787b5acc92f69ac..9feb27df12b0036b52b43b723a5c19294995b25e 100644 (file)
@@ -10,20 +10,11 @@ env:
 
 jobs:
   build:
-    name: Build (${{ matrix.tag }})
+    name: Build & Push
     runs-on: ubuntu-latest
     permissions:
       contents: read
       packages: write
-    strategy:
-      matrix:
-        include:
-          - tag: en
-            languages: "en"
-            latest: true
-          - tag: eu
-            languages: "en,de,es,fr,it,nl,pl,pt,ro"
-            latest: false
     steps:
       - name: Free disk space
         uses: endersonmenezes/free-disk-space@v3
@@ -53,11 +44,8 @@ jobs:
           file: docker/Dockerfile
           platforms: linux/amd64,linux/arm64
           push: true
-          build-args: LANGUAGES=${{ matrix.languages }}
           tags: |
-            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.tag }}
-            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-${{ matrix.tag }}
-            ${{ matrix.latest && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }}
-            ${{ matrix.latest && format('{0}/{1}:{2}', env.REGISTRY, env.IMAGE_NAME, steps.version.outputs.version) || '' }}
-          cache-from: type=gha,scope=${{ matrix.tag }}
-          cache-to: type=gha,mode=max,scope=${{ matrix.tag }}
+            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
index a1298da37eea9b9ef2b5a201bf9300675ec4171b..de4775198d368ddea03a5da0ae04910c0287ba37 100644 (file)
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ Primary endpoints:
 - Validation: Zod
 - Styling: Tailwind CSS v4
 - Database: SQLite at `data/pasteguard.db`
-- PII detection: Microsoft Presidio
+- PII detection: GLiNER + regex/checksum detector service (`detector/`) exposing `/analyze`
 - Formatting/linting: Biome
 
 ## Commands
index 7e69d7c918ffe6e1c6196b32c2a09499f93e3609..7b1bb09ea8b93cb273428b20d950a24b4e549f4c 100644 (file)
@@ -7,7 +7,7 @@ Thank you for considering contributing to PasteGuard!
 1. Fork and clone the repository
 2. Install dependencies: `bun install`
 3. Copy config: `cp config.example.yaml config.yaml`
-4. Start Presidio: `docker compose up presidio -d`
+4. Start the detector: `docker compose up detector -d`
 5. Run dev server: `bun run dev`
 
 ## Code Quality
index 7ad2d004aa29deff3bbd148bd5745a1b7db9fc7d..54ec8ba47bc4ce43d51ed96d05dd3e46b4edd2ab 100644 (file)
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@
 </picture>
 
 <p align="center">
-  Detects 30+ types of sensitive data across 24 languages.<br>
+  Detects personal data and secrets in many languages.<br>
   Your data never leaves your machine.
 </p>
 
@@ -52,7 +52,7 @@
 Run PasteGuard as a local proxy:
 
 ```bash
-docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:en
+docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:latest
 ```
 
 Point your tools or app to PasteGuard instead of the provider:
@@ -68,18 +68,7 @@ Point your tools or app to PasteGuard instead of the provider:
 client = OpenAI(base_url="http://localhost:3000/openai/v1")
 ```
 
-<details>
-<summary><strong>European Languages</strong></summary>
-
-For German, Spanish, French, Italian, Dutch, Polish, Portuguese, and Romanian:
-
-```bash
-docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:eu
-```
-
-For custom config, persistent logs, or other languages: **[Read the docs →](https://pasteguard.com/docs/installation)**
-
-</details>
+Detection is multilingual out of the box — no per-language images or setup. For custom config or persistent logs: **[Read the docs →](https://pasteguard.com/docs/installation)**
 
 <details>
 <summary><strong>Route Mode</strong></summary>
@@ -128,15 +117,21 @@ Every request is logged with masking details. See what was detected, what was ma
 
 ## What it catches
 
-**Personal data** — Names, emails, phone numbers, credit cards, IBANs, IP addresses, locations. Powered by [Microsoft Presidio](https://microsoft.github.io/presidio/). 24 languages.
+**Personal data** — Names, locations, emails, phone numbers, credit cards, IBANs, IP addresses, and EU VAT numbers. Works in many languages.
 
 **Secrets** — API keys (OpenAI, Anthropic, Stripe, AWS, GitHub), SSH and PEM private keys, JWT tokens, bearer tokens, passwords, connection strings.
 
 Both detected and masked in real time, including streaming responses.
 
+## How detection works
+
+Detection runs as a separate service that PasteGuard calls over HTTP, so you can run it wherever you like. It mixes two things: exact checks with checksums (IBANs, credit cards, emails, phones, IPs) and a small AI model ([GLiNER](https://github.com/urchade/GLiNER)) for names and places. It works the same in any language.
+
+Code, Docker image, and tests are in [`detector/`](detector/).
+
 ## Tech Stack
 
-[Bun](https://bun.sh) · [Hono](https://hono.dev) · [Microsoft Presidio](https://microsoft.github.io/presidio/) · SQLite
+[Bun](https://bun.sh) · [Hono](https://hono.dev) · [GLiNER](https://github.com/urchade/GLiNER) + [python-stdnum](https://arthurdejong.org/python-stdnum/) ([`detector/`](detector/)) · SQLite
 
 ## Contributing
 
index 31c6de662c235cfa129884738409a38454b9d794..f589327649712019f71723870196ff44c1bc6828 100644 (file)
@@ -11,6 +11,7 @@ export const CONFIGURED_PII_ENTITIES = [
   "LOCATION",
   "PERSON",
   "PHONE_NUMBER",
+  "VAT_CODE",
 ] as const;
 
 export const SUPPORTED_LANGUAGES = ["en", "de", "es", "fr", "it", "nl", "pl", "pt", "ro"] as const;
index 5fcc54a5a6d1fbd6d2eb58baf515fd2ccdf7f375..d847c2daf4463122a1c82f5bdbffa273a3f06487 100644 (file)
@@ -339,3 +339,36 @@ cases:
       - entity: LOCATION
         text: Berlin
         match: contains
+
+  - id: core_vat_de
+    suite: core
+    category: core
+    split: test
+    language: de
+    text: "Rechnung mit USt-IdNr DE136695976 anbei."
+    expected:
+      - entity: VAT_CODE
+        text: DE136695976
+        match: exact
+
+  - id: core_vat_it
+    suite: core
+    category: core
+    split: test
+    language: it
+    text: "Partita IVA IT00743110157 in fattura."
+    expected:
+      - entity: VAT_CODE
+        text: IT00743110157
+        match: exact
+
+  - id: core_vat_fr
+    suite: core
+    category: core
+    split: test
+    language: fr
+    text: "Numero de TVA FR40303265045 sur la facture."
+    expected:
+      - entity: VAT_CODE
+        text: FR40303265045
+        match: exact
index 7a49f70bbb419de1822469cfb3448e8c75c64b33..1d4dbc52a1fec29d60145fd438ee440afeabf529 100644 (file)
@@ -140,6 +140,8 @@ cases:
     split: test
     language: en
     entities: [LOCATION]
+    gate: false
+    note: "GLiNER tags cloud-region codes (eu-west-1, us-east-1) as LOCATION (~0.94); a known model limitation, tracked report-only (over-masking, not a leak)."
     text: "The workload moved from eu-west-1 to us-east-1 during the test."
     expected: []
 
@@ -185,6 +187,8 @@ cases:
     split: test
     language: pl
     entities: [LOCATION]
+    gate: false
+    note: "Cloud-region codes tagged as LOCATION after removing the structural cloud-region filter; tracked over-masking, report-only."
     text: "Zadanie przeniesiono z eu-west-1 do us-east-1 podczas testu."
     expected: []
 
@@ -195,7 +199,7 @@ cases:
     language: ro
     entities: [PERSON, LOCATION]
     gate: false
-    note: "Instance of a broader Presidio over-detection: non-name tokens (role nouns, emails, IBANs) are labelled PERSON/LOCATION at ~0.85 across several languages, e.g. 'Clientul' here. Tracked as a known false positive; report-only until recognizer precision improves."
+    note: "GLiNER tags the Romanian role noun 'Clientul' as PERSON (~0.85); the suppressor labels don't demote it here. Report-only."
     text: "Clientul cere factura până la sfârșitul săptămânii."
     expected: []
 
@@ -232,5 +236,53 @@ cases:
     split: test
     language: ro
     entities: [LOCATION]
+    gate: false
+    note: "Cloud-region codes tagged as LOCATION after removing the structural cloud-region filter; tracked over-masking, report-only."
     text: "Sarcina a fost mutată din eu-west-1 în us-east-1 în timpul testului."
     expected: []
+
+  # --- Known over-masking after removing the stoplist (report-only) ---
+  # These document false positives GLiNER produces with high confidence and that
+  # no competing suppressor label separates. Tracked here so the benchmark stops
+  # hiding them; not gating.
+  - id: precision_de_pronoun_not_person
+    suite: precision
+    category: precision
+    split: test
+    language: de
+    entities: [PERSON, LOCATION]
+    gate: false
+    note: "GLiNER tags the German pronoun 'Ich' as PERSON (~0.97), above the floor and not separable by a competing label. Report-only."
+    text: "Ich habe die Rechnung bereits bezahlt."
+    expected: []
+
+  - id: precision_it_common_noun_not_location
+    suite: precision
+    category: precision
+    split: test
+    language: it
+    entities: [PERSON, LOCATION]
+    gate: false
+    note: "GLiNER tags the Italian common noun 'Indirizzo' (address) as a location (~0.8). Report-only."
+    text: "Indirizzo del cliente non presente in fattura."
+    expected: []
+
+  - id: precision_de_mandant_not_person
+    suite: precision
+    category: precision
+    split: test
+    language: de
+    entities: [PERSON, LOCATION]
+    gate: false
+    note: "GLiNER tags the German term 'Mandant' (client) as PERSON (~0.9) even against suppressor labels; it is not suppressed. Report-only."
+    text: "Der Mandant hat die Unterlagen eingereicht."
+    expected: []
+
+  - id: precision_invalid_vat_checksum
+    suite: precision
+    category: precision
+    split: test
+    language: de
+    entities: [VAT_CODE]
+    text: "Die Referenz DE136695977 hat keine gueltige Pruefsumme."
+    expected: []
index 00d7da466b9c366901bf6ef925eb17e13c2d3022..575cfc13e32b4a42eebfc8e96b63a7e2d600f478 100644 (file)
@@ -54,48 +54,38 @@ masking:
   # whitelist:
   #   - "Company Name Inc."
 
-# PII Detection settings (Microsoft Presidio)
+# PII Detection settings (detector service, /analyze contract)
 pii_detection:
-  presidio_url: ${PRESIDIO_URL:-http://localhost:5002}
-
-  # Supported languages for PII detection
-  # Auto-detects language from input text and uses appropriate model
-  #
-  # Docker: Uses PASTEGUARD_LANGUAGES env var (auto-configured per image)
-  # Local:  Uncomment the array below and comment out the env var line
-  #
-  # Available (24 languages): ca, zh, hr, da, nl, en, fi, fr, de, el,
-  #   it, ja, ko, lt, mk, nb, pl, pt, ro, ru, sl, es, sv, uk
-  # See docker/presidio/languages.yaml for full list with details
-  languages: ${PASTEGUARD_LANGUAGES:-en}
-  # languages:
-  #   - en
-  #   - de
-  #   - fr
-  #   - es
-  #   - it
+  # URL of the detector service (see detector/). Defaults to localhost, which is
+  # correct both for the all-in-one image (detector in the same container) and
+  # for local dev (bun run dev against the docker-compose detector service).
+  detector_url: ${DETECTOR_URL:-http://localhost:5002}
+
+  # Detection is multilingual and language-agnostic — one model finds entities
+  # regardless of the language. This list is only a hint (e.g. phone-number
+  # region) and the language reported in headers; it does not gate detection.
+  languages:
+    - en
+    # - de
+    # - fr
+    # - es
+    # - it
 
   # Fallback language if detected language is not in the list above
   fallback_language: en
 
   score_threshold: 0.7  # Minimum confidence score (0.0 - 1.0)
 
-  # Entity types to detect
-  # See: https://microsoft.github.io/presidio/supported_entities/
+  # Entity types to return
   entities:
     - PERSON
+    - LOCATION
     - EMAIL_ADDRESS
     - PHONE_NUMBER
     - CREDIT_CARD
     - IBAN_CODE
     - IP_ADDRESS
-    - LOCATION
-    # - US_SSN
-    # - US_PASSPORT
-    # - CRYPTO
-    # - NRP  # National Registration Number
-    # - MEDICAL_LICENSE
-    # - URL
+    - VAT_CODE  # EU VAT number (any member state; requires country prefix)
 
   # Which message roles to scan for PII (optional)
   # By default, all roles are scanned. Set this to scan only user-controlled content:
diff --git a/detector/.gitignore b/detector/.gitignore
new file mode 100644 (file)
index 0000000..6fecb2b
--- /dev/null
@@ -0,0 +1,5 @@
+__pycache__/
+*.pyc
+.pytest_cache/
+*.egg-info/
+.venv/
diff --git a/detector/detector/__init__.py b/detector/detector/__init__.py
new file mode 100644 (file)
index 0000000..fb07dd4
--- /dev/null
@@ -0,0 +1,4 @@
+"""PasteGuard PII detector: an /analyze service over a deterministic
+regex/checksum layer plus multilingual GLiNER NER."""
+
+__version__ = "0.1.0"
diff --git a/detector/detector/app.py b/detector/detector/app.py
new file mode 100644 (file)
index 0000000..e89c5e1
--- /dev/null
@@ -0,0 +1,81 @@
+"""The /analyze service PasteGuard speaks (src/pii/detect.ts):
+  POST /analyze  {text, language, entities?, score_threshold?}
+                 -> [{entity_type, start, end, score}]  (offsets into text)
+  GET  /health   -> 200
+
+The service is language-agnostic: every language returns 200 with a (possibly
+empty) array, so the PasteGuard language probe always treats configured
+languages as supported.
+"""
+
+from __future__ import annotations
+
+from bisect import bisect_left
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+from .deterministic import detect_deterministic
+from .gliner_layer import detect_gliner, load_model
+from .merge import merge
+
+
+def _utf16_mapper(text: str):
+    """Return a function mapping a Python codepoint offset to a UTF-16 code-unit
+    offset. PasteGuard runs in JS and slices the returned offsets as UTF-16
+    (`text.slice`), so an astral-plane character (emoji, rare CJK > U+FFFF)
+    before a span would otherwise misalign the mask. Identity for all-BMP text.
+    """
+    astral = [i for i, c in enumerate(text) if ord(c) > 0xFFFF]
+    if not astral:
+        return lambda pos: pos
+    return lambda pos: pos + bisect_left(astral, pos)
+
+
+class AnalyzeRequest(BaseModel):
+    text: str
+    language: str = ""
+    entities: list[str] | None = None
+    score_threshold: float = 0.0
+
+
+class Entity(BaseModel):
+    entity_type: str
+    start: int
+    end: int
+    score: float
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+    # Load the model before serving so /health == ready (PasteGuard polls it).
+    load_model()
+    yield
+
+
+app = FastAPI(title="PasteGuard Detector", version="0.1.0", lifespan=lifespan)
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.post("/analyze", response_model=list[Entity])
+def analyze(req: AnalyzeRequest) -> list[Entity]:
+    deterministic = detect_deterministic(req.text, req.language)
+    # NER already applied per-label floors, so merge must not re-drop by the
+    # global threshold; deterministic spans are 1.0, so a 0.0 floor is safe.
+    fuzzy = detect_gliner(req.text, req.score_threshold)
+    spans = merge(deterministic, fuzzy, req.entities, 0.0)
+    to_u16 = _utf16_mapper(req.text)
+    return [
+        Entity(
+            entity_type=s.entity_type,
+            start=to_u16(s.start),
+            end=to_u16(s.end),
+            score=round(s.score, 4),
+        )
+        for s in spans
+    ]
diff --git a/detector/detector/deterministic.py b/detector/detector/deterministic.py
new file mode 100644 (file)
index 0000000..35fad55
--- /dev/null
@@ -0,0 +1,193 @@
+"""Deterministic layer: regex candidates gated by checksum/format validation.
+
+Owns the structured identifiers. Every match scores 1.0 because it is
+checksum-validated, not guessed. Detectors run in priority order and a later
+detector never claims a span that overlaps one already accepted, so an IBAN is
+not also reported as a credit card, etc.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import re
+
+import phonenumbers
+from stdnum import iban as _iban_lib
+from stdnum import luhn as _luhn
+from stdnum.eu import vat as _eu_vat
+
+from .entities import (
+    CREDIT_CARD,
+    EMAIL_ADDRESS,
+    IBAN_CODE,
+    IP_ADDRESS,
+    PHONE_NUMBER,
+    VAT_CODE,
+    Span,
+    overlaps,
+)
+
+# Default phonenumbers region per request language, so national-format numbers
+# (not only +international) are matched.
+LANG_TO_REGION = {
+    "de": "DE",
+    "it": "IT",
+    "fr": "FR",
+    "es": "ES",
+    "en": "US",
+    "nl": "NL",
+    "pt": "PT",
+    "pl": "PL",
+    "ro": "RO",
+}
+EXTRA_LANGUAGE_REGIONS = {
+    # English benchmark and real traffic may contain either US or UK national
+    # formats. The primary region stays US; GB is an additional pass.
+    "en": ["GB"],
+    # Conservative CLDR-backed language regions where national phone formats
+    # are common in same-language text.
+    "de": ["AT", "CH"],
+    "fr": ["BE", "CH", "LU"],
+    "nl": ["BE"],
+    "pt": ["BR"],
+    "ro": ["MD"],
+}
+
+# `\w` is Unicode-aware so accented names (müller@, andré.) match in full;
+# structure rejects leading/trailing/consecutive dots.
+_EMAIL_RE = re.compile(
+    r"(?<![\w.%+\-@])"
+    r"[\w%+\-]+(?:\.[\w%+\-]+)*"
+    r"@(?:[\w\-]+\.)+[^\W\d_]{2,}"
+    r"(?![\w\-])"
+)
+# A trailing "." is allowed (sentence punctuation); it is only rejected when it
+# starts another octet (\.\d), which would make the token a longer dotted-numeric
+# string rather than an IPv4 address.
+_IPV4_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w])(?!\.\d)")
+# IBAN: country + check digits then space-grouped alnum. Case-insensitive, so a
+# lowercase IBAN is also matched; since lowercase can't be told from prose by
+# case, _iban() validates and trims trailing tokens with stdnum to stop the bleed.
+_IBAN_RE = re.compile(
+    r"(?<![A-Za-z0-9])[A-Za-z]{2}[0-9]{2}(?:[ ]?[A-Za-z0-9]){11,30}(?![A-Za-z0-9])"
+)
+_CC_RE = re.compile(r"(?<![\d])(?:\d[ \-]?){13,19}(?<![\s\-])(?!\d)")
+# IPv6: generous candidate (hex/colon/dot) gated by Python's ipaddress parser.
+_IPV6_RE = re.compile(r"(?<![\w:.])[0-9A-Fa-f.:]{2,45}(?![\w:.])")
+# EU VAT country prefixes; stdnum.eu.vat validates the per-country checksum.
+_VAT_CC = "AT|BE|BG|HR|CY|CZ|DK|EE|FI|FR|DE|EL|GR|HU|IE|IT|LV|LT|LU|MT|NL|PL|PT|RO|SK|SI|ES|SE|EU"
+# Overlapping (lookahead) candidates so a word prefix like "it" can't hide a real VAT.
+_VAT_RE = re.compile(
+    r"(?<![A-Za-z0-9])(?=(?P<code>" + _VAT_CC + r")[ ]?(?P<body>[0-9A-Za-z]{8,12})(?![A-Za-z0-9]))",
+    re.IGNORECASE,
+)
+
+
+def _email(text: str) -> list[Span]:
+    return [Span(EMAIL_ADDRESS, m.start(), m.end(), 1.0) for m in _EMAIL_RE.finditer(text)]
+
+
+def _ipv4(text: str) -> list[Span]:
+    out: list[Span] = []
+    for m in _IPV4_RE.finditer(text):
+        if all(0 <= int(o) <= 255 for o in m.group().split(".")):
+            out.append(Span(IP_ADDRESS, m.start(), m.end(), 1.0))
+    return out
+
+
+def _ipv6(text: str) -> list[Span]:
+    out: list[Span] = []
+    for m in _IPV6_RE.finditer(text):
+        s = m.group()
+        end = m.end()
+        # "." is in the candidate class, so a sentence-ending period is captured
+        # too; trim trailing dots before validating so an IPv6 that ends a
+        # sentence still parses (mirrors the IPv4 trailing-period handling).
+        while s.endswith("."):
+            s = s[:-1]
+            end -= 1
+        if ":" not in s:
+            continue
+        try:
+            ipaddress.IPv6Address(s)
+        except ValueError:
+            continue
+        out.append(Span(IP_ADDRESS, m.start(), end, 1.0))
+    return out
+
+
+def _iban(text: str) -> list[Span]:
+    out: list[Span] = []
+    for m in _IBAN_RE.finditer(text):
+        # The match may have run past the IBAN into following prose (a lowercase
+        # IBAN is indistinguishable from prose by case). Trim trailing space-
+        # separated tokens until the candidate validates; tokens are single-space
+        # joined, so the trimmed candidate is an exact prefix of the match.
+        tokens = m.group().split(" ")
+        while tokens:
+            candidate = " ".join(tokens)
+            if _iban_lib.is_valid(candidate.replace(" ", "")):
+                out.append(Span(IBAN_CODE, m.start(), m.start() + len(candidate), 1.0))
+                break
+            tokens.pop()
+    return out
+
+
+def _vat(text: str) -> list[Span]:
+    out: list[Span] = []
+    for m in _VAT_RE.finditer(text):
+        if _eu_vat.is_valid(m.group("code") + m.group("body")):
+            out.append(Span(VAT_CODE, m.start("code"), m.end("body"), 1.0))
+    return out
+
+
+def _credit_card(text: str) -> list[Span]:
+    out: list[Span] = []
+    for m in _CC_RE.finditer(text):
+        digits = re.sub(r"[ \-]", "", m.group())
+        if 13 <= len(digits) <= 19 and _luhn.is_valid(digits):
+            out.append(Span(CREDIT_CARD, m.start(), m.end(), 1.0))
+    return out
+
+
+def _phone(text: str, language: str) -> list[Span]:
+    language_key = (language or "").lower()
+    region = LANG_TO_REGION.get(language_key)
+    regions = [region] if region else []
+    regions.extend(EXTRA_LANGUAGE_REGIONS.get(language_key, []))
+    out: list[Span] = []
+    # VALID leniency: only well-formed, assignable numbers. POSSIBLE would flag
+    # long invoice/ID digit runs as phones.
+    for candidate_region in regions or [None]:
+        for match in phonenumbers.PhoneNumberMatcher(
+            text, candidate_region, leniency=phonenumbers.Leniency.VALID
+        ):
+            span = Span(PHONE_NUMBER, match.start, match.end, 1.0)
+            if not any(s.start == span.start and s.end == span.end for s in out):
+                out.append(span)
+    return out
+
+
+# Priority order: most specific first. A later detector's span is dropped if it
+# overlaps an already-accepted one.
+def detect_deterministic(text: str, language: str = "") -> list[Span]:
+    if not text:
+        return []
+
+    ordered: list[Span] = []
+    ordered += _email(text)
+    # IPv6 before IPv4 so an IPv4-mapped/embedded IPv6 (e.g. ::ffff:192.168.0.1)
+    # is claimed in full, not truncated to its IPv4 tail by overlap resolution.
+    ordered += _ipv6(text)
+    ordered += _ipv4(text)
+    ordered += _iban(text)
+    ordered += _vat(text)
+    ordered += _credit_card(text)
+    ordered += _phone(text, language)
+
+    accepted: list[Span] = []
+    for span in ordered:
+        if any(overlaps(span, a) for a in accepted):
+            continue
+        accepted.append(span)
+    return accepted
diff --git a/detector/detector/entities.py b/detector/detector/entities.py
new file mode 100644 (file)
index 0000000..bc8ee73
--- /dev/null
@@ -0,0 +1,41 @@
+"""Entity types and the internal span representation.
+
+Entity type strings are the labels PasteGuard expects on the /analyze response
+(see src/pii/detect.ts).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+# Entity type strings returned on the /analyze response. The Presidio drop-in
+# set plus VAT_CODE (EU VAT numbers, checksum-validated). Other structured types
+# (org/address/fiscal/BIC/crypto) are not currently emitted.
+PERSON = "PERSON"
+LOCATION = "LOCATION"
+EMAIL_ADDRESS = "EMAIL_ADDRESS"
+PHONE_NUMBER = "PHONE_NUMBER"
+CREDIT_CARD = "CREDIT_CARD"
+IBAN_CODE = "IBAN_CODE"
+IP_ADDRESS = "IP_ADDRESS"
+VAT_CODE = "VAT_CODE"
+
+
+@dataclass(frozen=True)
+class Span:
+    """A detected entity span. `start`/`end` are character offsets into the
+    submitted text; `score` is in [0, 1] (deterministic matches are 1.0)."""
+
+    entity_type: str
+    start: int
+    end: int
+    score: float
+
+    @property
+    def length(self) -> int:
+        return self.end - self.start
+
+
+def overlaps(a: Span, b: Span) -> bool:
+    """Half-open span overlap (touching spans, end == start, do not overlap)."""
+    return a.start < b.end and b.start < a.end
diff --git a/detector/detector/gliner_layer.py b/detector/detector/gliner_layer.py
new file mode 100644 (file)
index 0000000..2b23756
--- /dev/null
@@ -0,0 +1,151 @@
+"""Fuzzy layer: multilingual GLiNER NER for person, location, and address
+(addresses are emitted as LOCATION). Generic role nouns are demoted via
+competing suppressor labels rather than a per-language denylist.
+
+Each label has its own confidence floor (PER_LABEL_FLOOR). The request
+`score_threshold` only raises the tunable labels (person, location, address).
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import threading
+from typing import Any
+
+from .entities import LOCATION, PERSON, Span
+
+DEFAULT_MODEL = "urchade/gliner_multi_pii-v1"
+
+
+def _floor(label: str, default: float) -> float:
+    return float(os.environ.get(f"DETECTOR_FLOOR_{label.upper()}", default))
+
+
+# Per-label confidence floors (calibrated against the accuracy benchmark;
+# overridable via env, e.g. DETECTOR_FLOOR_LOCATION=0.6). Role nouns are demoted
+# by the suppressor labels (below), not by this floor.
+PER_LABEL_FLOOR = {
+    "person": _floor("person", 0.85),
+    "location": _floor("location", 0.50),
+    # A dedicated "address" label recovers full street addresses that a bare
+    # "location" reading misses; emitted as LOCATION (see _LABEL_TO_TYPE).
+    "address": _floor("address", 0.70),
+}
+# Labels the request `score_threshold` may raise (high-volume, deployment-tunable).
+_TUNABLE = {"person", "location", "address"}
+
+_LABELS = list(PER_LABEL_FLOOR)
+_LABEL_TO_TYPE = {
+    "person": PERSON,
+    "location": LOCATION,
+    # Street addresses are a kind of location for masking purposes; emit them as
+    # LOCATION so the response entity set stays the Presidio drop-in set.
+    "address": LOCATION,
+}
+# Suppressor labels: predicted but never emitted. They give GLiNER a competing
+# reading for generic role nouns (client/customer/...) in any language, so it
+# tags those instead of "person" — no per-language denylist needed.
+_SUPPRESS_LABELS = ["customer", "role"]
+_PREDICT_LABELS = _LABELS + _SUPPRESS_LABELS
+# Capture candidates below every floor so per-label filtering has them.
+_PREDICT_FLOOR = min(PER_LABEL_FLOOR.values()) - 0.1
+
+# GLiNER truncates input past its word-token limit (~384), so long text would
+# drop PII past the cut. Split into overlapping windows; the splitter mirrors
+# GLiNER's WhitespaceTokenSplitter so window sizes match.
+_TOKEN_RE = re.compile(r"\w+(?:[-_]\w+)*|\S")
+_MAX_TOKENS = int(os.environ.get("DETECTOR_MAX_TOKENS", "384"))
+_WINDOW = max(64, _MAX_TOKENS - 64)  # headroom under the hard limit
+_OVERLAP = 64  # >= longest expected entity, so boundary-straddling spans survive
+
+
+def _windows(text: str):
+    """Yield (char_offset, subtext) windows. One window for short text; for long
+    text, overlapping windows of <= _WINDOW word-tokens."""
+    toks = [(m.start(), m.end()) for m in _TOKEN_RE.finditer(text)]
+    if len(toks) <= _MAX_TOKENS:
+        yield 0, text
+        return
+    step = max(1, _WINDOW - _OVERLAP)
+    i = 0
+    while i < len(toks):
+        window = toks[i : i + _WINDOW]
+        cstart, cend = window[0][0], window[-1][1]
+        yield cstart, text[cstart:cend]
+        if i + _WINDOW >= len(toks):
+            break
+        i += step
+
+
+# GLiNER ships no type stubs, so the loaded model is untyped (Any).
+_model: Any = None
+_lock = threading.Lock()
+# Torch inference is not guaranteed thread-safe; serialize concurrent /analyze calls.
+_infer_lock = threading.Lock()
+
+
+def _model_name() -> str:
+    return (
+        os.environ.get("DETECTOR_MODEL_PATH") or os.environ.get("DETECTOR_MODEL") or DEFAULT_MODEL
+    )
+
+
+def load_model() -> None:
+    """Load the model once. Safe to call at startup or lazily."""
+    global _model
+    if _model is not None:
+        return
+    with _lock:
+        if _model is not None:
+            return
+        from gliner import GLiNER
+
+        _model = GLiNER.from_pretrained(_model_name())
+
+
+def detect_gliner(text: str, score_threshold: float = 0.0) -> list[Span]:
+    if not text:
+        return []
+    load_model()
+    n = len(text)
+    # Run each window, shift spans back to absolute offsets, dedupe overlaps
+    # (same span+label) keeping the max score.
+    best: dict[tuple[int, int, str], float] = {}
+    with _infer_lock:
+        for offset, sub in _windows(text):
+            for ent in _model.predict_entities(
+                sub, _PREDICT_LABELS, threshold=max(0.0, _PREDICT_FLOOR)
+            ):
+                key = (offset + int(ent["start"]), offset + int(ent["end"]), ent["label"])
+                score = float(ent["score"])
+                if score > best.get(key, -1.0):
+                    best[key] = score
+
+    # Highest suppressor score per span: if a "customer"/"role" reading of the
+    # exact same span outscores its entity reading, it is a role noun, not PII.
+    suppressor: dict[tuple[int, int], float] = {}
+    for (start, end, label), score in best.items():
+        if label in _SUPPRESS_LABELS and score > suppressor.get((start, end), -1.0):
+            suppressor[start, end] = score
+
+    out: list[Span] = []
+    for (start, end, label), score in best.items():
+        if label in _SUPPRESS_LABELS:
+            continue
+        # label is always one of _LABELS (== _LABEL_TO_TYPE keys), so a direct
+        # lookup is safe.
+        etype = _LABEL_TO_TYPE[label]
+        floor = PER_LABEL_FLOOR[label]
+        if label in _TUNABLE:
+            floor = max(floor, score_threshold)
+        if score < floor:
+            continue
+        # A stronger role-noun reading of the same span wins (drops the entity).
+        if score < suppressor.get((start, end), -1.0):
+            continue
+        # Drop out-of-bounds spans from tokenization bugs (would mask wrong text).
+        if not 0 <= start < end <= n:
+            continue
+        out.append(Span(etype, start, end, score))
+    return out
diff --git a/detector/detector/merge.py b/detector/detector/merge.py
new file mode 100644 (file)
index 0000000..97815e0
--- /dev/null
@@ -0,0 +1,44 @@
+"""Merge the deterministic and fuzzy layers into the final entity list.
+
+Rules:
+  * deterministic spans (score 1.0) always outrank fuzzy spans on overlap;
+  * among fuzzy spans, the longer one wins, then the higher score;
+  * fuzzy spans below `score_threshold` are dropped (deterministic = 1.0 always
+    passes);
+  * the result is filtered to the requested `entities` and sorted by start.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from .entities import Span, overlaps
+
+
+def merge(
+    deterministic: list[Span],
+    fuzzy: list[Span],
+    entities: Iterable[str] | None = None,
+    score_threshold: float = 0.0,
+) -> list[Span]:
+    # Restrict to the requested types up front. Filtering only at the end would
+    # let a longer non-requested span win an overlap and suppress a requested
+    # one, then be dropped itself — silently losing the requested entity.
+    if entities:
+        allow = set(entities)
+        deterministic = [s for s in deterministic if s.entity_type in allow]
+        fuzzy = [s for s in fuzzy if s.entity_type in allow]
+
+    # Deterministic spans are pre-resolved (non-overlapping) and take precedence.
+    accepted: list[Span] = [s for s in deterministic if s.score >= score_threshold]
+
+    # Longer fuzzy spans first, then higher score, for stable overlap resolution.
+    for span in sorted(fuzzy, key=lambda s: (-s.length, -s.score)):
+        if span.score < score_threshold:
+            continue
+        if any(overlaps(span, a) for a in accepted):
+            continue
+        accepted.append(span)
+
+    accepted.sort(key=lambda s: (s.start, s.end))
+    return accepted
diff --git a/detector/pyproject.toml b/detector/pyproject.toml
new file mode 100644 (file)
index 0000000..2210da6
--- /dev/null
@@ -0,0 +1,44 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pasteguard-detector"
+version = "0.1.0"
+description = "/analyze PII detector: deterministic regex/checksum + multilingual GLiNER NER."
+requires-python = ">=3.10"
+license = { text = "Apache-2.0" }
+dependencies = [
+    "fastapi>=0.110",
+    "uvicorn>=0.29",
+    "pydantic>=2",
+    "gliner>=0.2.13",
+    "python-stdnum>=1.20",
+    "phonenumbers>=8.13",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "httpx>=0.27", "ruff>=0.6", "pyright>=1.1"]
+
+[tool.setuptools]
+packages = ["detector"]
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+
+[tool.ruff]
+line-length = 100
+
+[tool.ruff.lint]
+select = ["F", "E", "W", "I", "UP", "B", "SIM", "RUF"]
+
+[tool.pyright]
+include = ["detector"]
+venvPath = "."
+venv = ".venv"
+pythonVersion = "3.10"
+typeCheckingMode = "basic"
+# Third-party deps (gliner, python-stdnum, phonenumbers) ship no type stubs;
+# their symbols are treated as untyped rather than flagged.
+reportMissingTypeStubs = false
diff --git a/detector/tests/test_analyze.py b/detector/tests/test_analyze.py
new file mode 100644 (file)
index 0000000..a168d9c
--- /dev/null
@@ -0,0 +1,109 @@
+"""Integration tests for the /analyze HTTP contract.
+
+GLiNER is stubbed so these are fast and deterministic; the deterministic layer
+and the merge/contract behaviour are exercised for real.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+
+import detector.app as appmod
+from detector.entities import LOCATION, PERSON, Span
+
+
+@pytest.fixture
+def client(monkeypatch):
+    monkeypatch.setattr(appmod, "load_model", lambda: None)
+
+    def fake_gliner(text, score_threshold=0.0):
+        spans = []
+        for needle, etype in (("Mario Rossi", PERSON), ("München", LOCATION)):
+            i = text.find(needle)
+            # person/location are tunable: honor the request threshold like the
+            # real per-label floor would.
+            if i >= 0 and score_threshold <= 0.95:
+                spans.append(Span(etype, i, i + len(needle), 0.95))
+        return spans
+
+    monkeypatch.setattr(appmod, "detect_gliner", fake_gliner)
+    with TestClient(appmod.app) as c:
+        yield c
+
+
+def test_health(client):
+    r = client.get("/health")
+    assert r.status_code == 200
+    assert r.json() == {"status": "ok"}
+
+
+def test_response_shape_and_offsets(client):
+    text = "IBAN IT60X0542811101000000123456"
+    r = client.post("/analyze", json={"text": text, "language": "it", "score_threshold": 0.7})
+    assert r.status_code == 200
+    body = r.json()
+    assert body and all({"entity_type", "start", "end", "score"} == set(e) for e in body)
+    for e in body:
+        # offsets index into the submitted text
+        assert text[e["start"] : e["end"]]
+    assert any(e["entity_type"] == "IBAN_CODE" for e in body)
+
+
+def test_routing_iban_in_german_text(client):
+    # An Italian IBAN inside German text, language=de — the deterministic layer
+    # is language-agnostic, so it must still be found.
+    text = "Der Mandant Mario Rossi (IT60X0542811101000000123456) zahlt."
+    r = client.post("/analyze", json={"text": text, "language": "de", "score_threshold": 0.7})
+    types = {e["entity_type"] for e in r.json()}
+    assert "IBAN_CODE" in types
+    assert "PERSON" in types  # from the (stubbed) multilingual NER
+
+
+def test_entity_filter(client):
+    text = "Mario Rossi, IBAN IT60X0542811101000000123456"
+    r = client.post(
+        "/analyze",
+        json={
+            "text": text,
+            "language": "it",
+            "entities": ["IBAN_CODE"],
+            "score_threshold": 0.7,
+        },
+    )
+    types = {e["entity_type"] for e in r.json()}
+    assert types == {"IBAN_CODE"}
+
+
+def test_score_threshold_drops_fuzzy_keeps_deterministic(client):
+    text = "Mario Rossi, IBAN IT60X0542811101000000123456"
+    r = client.post("/analyze", json={"text": text, "language": "it", "score_threshold": 0.99})
+    types = {e["entity_type"] for e in r.json()}
+    assert "IBAN_CODE" in types  # deterministic, score 1.0
+    assert "PERSON" not in types  # fuzzy 0.95 < 0.99
+
+
+def test_language_probe_never_errors(client):
+    # The PasteGuard probe: any language must return 200 with an array, never
+    # an error, so every configured language is treated as supported.
+    r = client.post("/analyze", json={"text": "test", "language": "xx", "entities": ["PERSON"]})
+    assert r.status_code == 200
+    assert isinstance(r.json(), list)
+
+
+def test_empty_text(client):
+    r = client.post("/analyze", json={"text": "", "language": "de"})
+    assert r.status_code == 200
+    assert r.json() == []
+
+
+def test_utf16_offsets_with_astral_char(client):
+    # An emoji (astral, 2 UTF-16 code units) before the email must shift the
+    # returned offsets so PasteGuard's JS text.slice lands on the email.
+    text = "Hi 😀 mail@x.com"
+    r = client.post("/analyze", json={"text": text, "language": "en", "score_threshold": 0.7})
+    email = next(e for e in r.json() if e["entity_type"] == "EMAIL_ADDRESS")
+    # JS code units: "Hi " (3) + emoji (2) + " " (1) = 6
+    assert email["start"] == 6
+    # Simulate the JS consumer: slice as UTF-16 code units.
+    u16 = text.encode("utf-16-le")
+    sliced = u16[email["start"] * 2 : email["end"] * 2].decode("utf-16-le")
+    assert sliced == "mail@x.com"
diff --git a/detector/tests/test_deterministic.py b/detector/tests/test_deterministic.py
new file mode 100644 (file)
index 0000000..607a256
--- /dev/null
@@ -0,0 +1,265 @@
+"""Unit tests for the deterministic (regex + checksum) layer."""
+
+from itertools import pairwise
+
+from detector.deterministic import detect_deterministic
+from detector.entities import (
+    CREDIT_CARD,
+    EMAIL_ADDRESS,
+    IBAN_CODE,
+    IP_ADDRESS,
+    PHONE_NUMBER,
+    VAT_CODE,
+)
+
+
+def types_texts(text, language=""):
+    return [(s.entity_type, text[s.start : s.end]) for s in detect_deterministic(text, language)]
+
+
+# --- IBAN ---
+def test_iban_plain():
+    assert (IBAN_CODE, "IT60X0542811101000000123456") in types_texts(
+        "IBAN: IT60X0542811101000000123456"
+    )
+
+
+def test_iban_spaced_keeps_spacing_in_span():
+    text = "Bonifico IBAN IT60 X054 2811 1010 0000 0123 456 entro lunedì"
+    assert (IBAN_CODE, "IT60 X054 2811 1010 0000 0123 456") in types_texts(text, "it")
+
+
+def test_iban_german():
+    assert (IBAN_CODE, "DE89 3704 0044 0532 0130 00") in types_texts(
+        "auf IBAN DE89 3704 0044 0532 0130 00", "de"
+    )
+
+
+def test_iban_invalid_checksum_rejected():
+    assert types_texts("IBAN errato: IT60X0542811101000000123457", "it") == []
+
+
+def test_iban_lowercase():
+    assert (IBAN_CODE, "de89 3704 0044 0532 0130 00") in types_texts(
+        "bitte auf iban de89 3704 0044 0532 0130 00", "de"
+    )
+
+
+def test_iban_lowercase_does_not_bleed_into_following_word():
+    spans = detect_deterministic("iban de89 3704 0044 0532 0130 00 grazie", "it")
+    iban = next(s for s in spans if s.entity_type == IBAN_CODE)
+    text = "iban de89 3704 0044 0532 0130 00 grazie"
+    assert "grazie" not in text[iban.start : iban.end]
+
+
+def test_iban_does_not_bleed_into_following_word():
+    # The lowercase word after the IBAN must not be swallowed.
+    spans = detect_deterministic("IBAN IT60 X054 2811 1010 0000 0123 456 entro", "it")
+    iban = next(s for s in spans if s.entity_type == IBAN_CODE)
+    assert "entro" not in "IBAN IT60 X054 2811 1010 0000 0123 456 entro"[iban.start : iban.end]
+
+
+# --- VAT (EU, stdnum-validated) ---
+def test_vat_valid_multiple_countries():
+    for v, lang in [
+        ("DE136695976", "de"),
+        ("IT00743110157", "it"),
+        ("FR40303265045", "fr"),
+        ("ESA13585625", "es"),
+        ("ATU13585627", "de"),
+        ("BE0428759497", "nl"),
+        ("PL5260001246", "pl"),
+    ]:
+        assert (VAT_CODE, v) in types_texts(f"VAT {v} on the invoice", lang)
+
+
+def test_vat_spaced_after_prefix():
+    assert (VAT_CODE, "DE 136695976") in types_texts("USt-IdNr DE 136695976", "de")
+
+
+def test_vat_invalid_checksum_rejected():
+    assert all(t != VAT_CODE for t, _ in types_texts("VAT DE136695977 is wrong", "de"))
+
+
+def test_vat_does_not_claim_iban():
+    # An IBAN must stay IBAN_CODE and never be mistagged as VAT.
+    text = "auf IBAN DE89 3704 0044 0532 0130 00"
+    types = types_texts(text, "de")
+    assert (IBAN_CODE, "DE89 3704 0044 0532 0130 00") in types
+    assert all(t != VAT_CODE for t, _ in types)
+
+
+def test_vat_label_prefix_not_absorbed():
+    # A 2-letter label before the VAT (e.g. "ID") must not be taken as the country prefix.
+    for text in ["Tax ID DE136695976 here", "Steuer-ID DE136695976 anbei"]:
+        assert (VAT_CODE, "DE136695976") in types_texts(text, "en")
+
+
+def test_vat_lowercase():
+    assert (VAT_CODE, "de136695976") in types_texts("the vat is de136695976.", "en")
+    assert (VAT_CODE, "it00743110157") in types_texts("partita iva it00743110157.", "it")
+
+
+def test_vat_word_prefix_does_not_swallow_following_vat():
+    # A lowercase word that is also a country code ("es", "it") must not hide the real VAT.
+    for text in ["es DE136695976", "it DE136695976"]:
+        assert (VAT_CODE, "DE136695976") in types_texts(text, "en")
+
+
+# --- Email / IP ---
+def test_email():
+    assert (EMAIL_ADDRESS, "john.doe@company.com") in types_texts("at john.doe@company.com")
+
+
+def test_email_keeps_plus_and_underscore():
+    assert (EMAIL_ADDRESS, "user+tag@example.com") in types_texts("to user+tag@example.com now")
+    assert (EMAIL_ADDRESS, "first_last@sub.example.co.uk") in types_texts(
+        "mail first_last@sub.example.co.uk here"
+    )
+
+
+def test_email_rejects_malformed():
+    for bad in ("user@example..com", "user.@example.com", "user@.example.com"):
+        assert all(t != EMAIL_ADDRESS for t, _ in types_texts(f"x {bad} y"))
+
+
+def test_email_unicode_local_part_no_partial_leak():
+    # Accented local parts must match in full, not leak a partial span.
+    assert (EMAIL_ADDRESS, "müller@example.com") in types_texts("an müller@example.com")
+    assert (EMAIL_ADDRESS, "andré.muller@example.fr") in types_texts("mail andré.muller@example.fr")
+
+
+def test_ipv4():
+    assert (IP_ADDRESS, "8.8.8.8") in types_texts("Server IP is 8.8.8.8")
+
+
+def test_ipv4_invalid_octet_rejected():
+    assert all(t != IP_ADDRESS for t, _ in types_texts("version 8.8.8.999 here"))
+
+
+def test_ipv4_trailing_period():
+    # A sentence-ending period must not hide the address.
+    assert (IP_ADDRESS, "8.8.8.8") in types_texts("Public DNS is 8.8.8.8.")
+
+
+def test_ipv4_five_octets_rejected():
+    # A fifth octet means it is a longer dotted-numeric token, not an IP.
+    assert all(t != IP_ADDRESS for t, _ in types_texts("version 8.8.8.8.8 here"))
+
+
+def test_ipv6_full():
+    addr = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
+    assert (IP_ADDRESS, addr) in types_texts(f"server at {addr} listens")
+
+
+def test_ipv6_compressed():
+    assert (IP_ADDRESS, "2001:db8::1") in types_texts("ping 2001:db8::1 now")
+
+
+def test_ipv6_trailing_period():
+    # A sentence-ending period must not hide the address (mirrors IPv4).
+    assert (IP_ADDRESS, "2001:db8::8a2e:370:7334") in types_texts(
+        "The compressed IPv6 address is 2001:db8::8a2e:370:7334."
+    )
+    addr = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
+    assert (IP_ADDRESS, addr) in types_texts(f"The IPv6 source address was {addr}.")
+
+
+def test_ipv6_invalid_rejected():
+    assert all(t != IP_ADDRESS for t, _ in types_texts("time was 12:34:56 today"))
+
+
+def test_ipv6_mapped_ipv4_keeps_full_span():
+    # IPv4-mapped IPv6 must be reported in full, not truncated to its IPv4 tail
+    # (which would leave the "::ffff:" prefix unmasked).
+    addr = "::ffff:192.168.0.1"
+    assert (IP_ADDRESS, addr) in types_texts(f"addr {addr} here")
+
+
+# --- Credit card ---
+def test_credit_card_valid_luhn():
+    assert (CREDIT_CARD, "4111 1111 1111 1111") in types_texts("Card: 4111 1111 1111 1111")
+
+
+def test_credit_card_invalid_luhn_rejected():
+    assert all(t != CREDIT_CARD for t, _ in types_texts("Card: 4111 1111 1111 1112"))
+
+
+# --- Phone (VALID leniency: no FP on long ID digit runs) ---
+def test_phone_german_national():
+    assert (PHONE_NUMBER, "0171-1234567") in types_texts("Telefon 0171-1234567", "de")
+
+
+def test_phone_international():
+    assert (PHONE_NUMBER, "+49 171 1234567") in types_texts("Tel: +49 171 1234567", "de")
+
+
+def test_phone_no_false_positive_on_invoice_number():
+    assert all(t != PHONE_NUMBER for t, _ in types_texts("Rechnung 2893081508152 vom", "de"))
+
+
+def test_phone_english_uk_national():
+    assert (PHONE_NUMBER, "0121 234 5678") in types_texts(
+        "The Birmingham callback number is 0121 234 5678.", "en"
+    )
+
+
+def test_phone_german_extra_regions():
+    assert (PHONE_NUMBER, "01 234567890") in types_texts(
+        "Die Wiener Kontaktnummer ist 01 234567890.", "de"
+    )
+    assert (PHONE_NUMBER, "0848 800 800") in types_texts(
+        "Die Schweizer Kontaktnummer ist 0848 800 800.", "de"
+    )
+
+
+def test_phone_french_extra_regions():
+    assert (PHONE_NUMBER, "012 34 56 78") in types_texts(
+        "Le numéro belge du contact est 012 34 56 78.", "fr"
+    )
+    assert (PHONE_NUMBER, "0848 800 800") in types_texts(
+        "Le numéro suisse du contact est 0848 800 800.", "fr"
+    )
+    assert (PHONE_NUMBER, "27 12 34 56") in types_texts(
+        "Le numéro luxembourgeois du contact est 27 12 34 56.", "fr"
+    )
+
+
+def test_phone_dutch_belgium_region():
+    assert (PHONE_NUMBER, "012 34 56 78") in types_texts(
+        "Het Belgische telefoonnummer is 012 34 56 78.", "nl"
+    )
+
+
+def test_phone_portuguese_brazil_region():
+    assert (PHONE_NUMBER, "(11) 2345-6789") in types_texts(
+        "O número brasileiro do contato é (11) 2345-6789.", "pt"
+    )
+
+
+def test_phone_polish_and_romanian_primary_regions():
+    assert (PHONE_NUMBER, "12 345 67 89") in types_texts(
+        "Numer krajowy kontaktu to 12 345 67 89.", "pl"
+    )
+    assert (PHONE_NUMBER, "021 123 4567") in types_texts(
+        "Numărul național al contactului este 021 123 4567.", "ro"
+    )
+
+
+def test_phone_romanian_moldova_region():
+    assert (PHONE_NUMBER, "022 212 345") in types_texts(
+        "Numărul de contact din Moldova este 022 212 345.", "ro"
+    )
+
+
+# --- overlap / priority ---
+def test_no_overlapping_spans():
+    text = "IBAN IT60 X054 2811 1010 0000 0123 456, mail luca@example.it"
+    spans = detect_deterministic(text, "it")
+    spans.sort(key=lambda s: s.start)
+    for a, b in pairwise(spans):
+        assert a.end <= b.start
+
+
+def test_empty_text():
+    assert detect_deterministic("", "de") == []
diff --git a/detector/tests/test_gliner_layer.py b/detector/tests/test_gliner_layer.py
new file mode 100644 (file)
index 0000000..1061158
--- /dev/null
@@ -0,0 +1,57 @@
+"""Unit tests for the GLiNER layer's precision calibration (no model load).
+
+The role-noun suppressor labels and per-label floors are the precision layer; the
+full model integration is covered by benchmarks/pii-accuracy.
+"""
+
+import pytest
+
+from detector.gliner_layer import (
+    _MAX_TOKENS,
+    _SUPPRESS_LABELS,
+    _TOKEN_RE,
+    PER_LABEL_FLOOR,
+    _windows,
+)
+
+
+def test_token_re_matches_gliner_splitter():
+    # Windowing correctness depends on our token regex matching GLiNER's own
+    # splitter exactly; pin it so a GLiNER change to the pattern fails here
+    # instead of silently truncating long inputs past the token limit.
+    try:
+        from gliner.data_processing.tokenizer import WhitespaceTokenSplitter
+    except Exception:
+        pytest.skip("gliner WhitespaceTokenSplitter not importable")
+    assert _TOKEN_RE.pattern == WhitespaceTokenSplitter().whitespace_pattern.pattern
+
+
+def test_role_nouns_handled_by_suppressor_labels():
+    # Generic role nouns are disambiguated language-agnostically by competing
+    # suppressor labels rather than any hard-coded denylist.
+    assert "customer" in _SUPPRESS_LABELS
+
+
+def test_windows_single_for_short_text():
+    text = "Mario Rossi lives in Rome."
+    assert list(_windows(text)) == [(0, text)]
+
+
+def test_windows_overlapping_and_cover_long_text():
+    # > _MAX_TOKENS word-tokens -> multiple windows that slice the original text
+    # correctly and reach the end (so trailing PII is never dropped).
+    text = " ".join(f"word{i}" for i in range(_MAX_TOKENS * 4))
+    wins = list(_windows(text))
+    assert len(wins) > 1
+    for off, sub in wins:
+        assert text[off : off + len(sub)] == sub
+    last_off, last_sub = wins[-1]
+    assert last_off + len(last_sub) == len(text)
+
+
+def test_per_label_floors_present_and_ordered():
+    assert set(PER_LABEL_FLOOR) == {"person", "location", "address"}
+    assert all(0.0 <= v <= 1.0 for v in PER_LABEL_FLOOR.values())
+    # Person carries a stricter floor than location: higher volume and no
+    # structural validator, so a higher floor curbs false positives.
+    assert PER_LABEL_FLOOR["location"] <= PER_LABEL_FLOOR["person"]
diff --git a/detector/tests/test_merge.py b/detector/tests/test_merge.py
new file mode 100644 (file)
index 0000000..4904572
--- /dev/null
@@ -0,0 +1,63 @@
+"""Unit tests for the merge / conflict-resolution layer."""
+
+from detector.entities import IBAN_CODE, LOCATION, PERSON, Span
+from detector.merge import merge
+
+
+def test_deterministic_precedence_over_overlapping_fuzzy():
+    det = [Span(IBAN_CODE, 0, 27, 1.0)]
+    fuzzy = [Span(LOCATION, 0, 4, 0.9)]  # e.g. "DE89" mis-tagged
+    out = merge(det, fuzzy, None, 0.7)
+    assert out == [Span(IBAN_CODE, 0, 27, 1.0)]
+
+
+def test_fuzzy_below_threshold_dropped():
+    out = merge([], [Span(PERSON, 0, 5, 0.5)], None, 0.7)
+    assert out == []
+
+
+def test_fuzzy_above_threshold_kept():
+    out = merge([], [Span(PERSON, 0, 5, 0.8)], None, 0.7)
+    assert out == [Span(PERSON, 0, 5, 0.8)]
+
+
+def test_deterministic_always_passes_threshold():
+    # score 1.0 >= any threshold <= 1
+    out = merge([Span(IBAN_CODE, 0, 4, 1.0)], [], None, 1.0)
+    assert out == [Span(IBAN_CODE, 0, 4, 1.0)]
+
+
+def test_entity_filter():
+    det = [Span(IBAN_CODE, 0, 4, 1.0)]
+    fuzzy = [Span(PERSON, 10, 15, 0.9)]
+    out = merge(det, fuzzy, [PERSON], 0.7)
+    assert out == [Span(PERSON, 10, 15, 0.9)]
+
+
+def test_fuzzy_overlap_longest_wins():
+    fuzzy = [Span(PERSON, 0, 5, 0.8), Span(PERSON, 0, 10, 0.8)]
+    out = merge([], fuzzy, None, 0.7)
+    assert out == [Span(PERSON, 0, 10, 0.8)]
+
+
+def test_result_sorted_by_start():
+    det = [Span(IBAN_CODE, 20, 30, 1.0)]
+    fuzzy = [Span(PERSON, 0, 5, 0.9)]
+    out = merge(det, fuzzy, None, 0.7)
+    assert [s.start for s in out] == [0, 20]
+
+
+def test_non_overlapping_both_kept():
+    det = [Span(IBAN_CODE, 0, 4, 1.0)]
+    fuzzy = [Span(PERSON, 10, 15, 0.9)]
+    out = merge(det, fuzzy, None, 0.7)
+    assert len(out) == 2
+
+
+def test_requested_entity_not_suppressed_by_non_requested_overlap():
+    # A longer non-requested span must not win the overlap and then be filtered
+    # out, which would drop the requested (shorter) span entirely. e.g. a longer
+    # LOCATION span overlapping the PERSON span when only PERSON is requested.
+    fuzzy = [Span(PERSON, 0, 11, 0.9), Span(LOCATION, 0, 15, 0.85)]
+    out = merge([], fuzzy, [PERSON], 0.0)
+    assert out == [Span(PERSON, 0, 11, 0.9)]
index f3c4f75df673356b7d648c5f609939d5d8884e26..dd9f0d3bfce82ceb6cd0caa0201d8bb8d066295e 100644 (file)
@@ -1,27 +1,23 @@
-# PasteGuard - Docker Compose (All-in-One Image)
+# PasteGuard - Docker Compose
 #
-# Production:
+# Production (all-in-one: proxy + detector in one container):
 #   docker compose up -d
 #
-# Development (only Presidio, run Bun locally with hot-reload):
-#   docker compose up presidio -d
+# Development (detector in Docker, proxy locally with hot-reload):
+#   docker compose up detector -d
 #   bun run dev
 #
-# European languages:
-#   PASTEGUARD_TAG=eu docker compose up -d
-#
-# Custom languages (local build):
-#   LANGUAGES=en,de,ja docker compose up -d --build
+# The published image bundles the proxy and the PII detector. The `detector`
+# service below builds the `detector` target of the SAME docker/Dockerfile, so
+# there is one detector definition; it exists only for local development, where
+# the proxy runs from source (`bun run dev`) against it.
 
 services:
-  # Production: Full all-in-one
   pasteguard:
-    image: ghcr.io/sgasser/pasteguard:${PASTEGUARD_TAG:-en}
+    image: ghcr.io/sgasser/pasteguard:latest
     build:
       context: .
       dockerfile: docker/Dockerfile
-      args:
-        LANGUAGES: ${LANGUAGES:-en}
     ports:
       - "3000:3000"
     env_file:
@@ -32,21 +28,15 @@ services:
       - ./data:/pasteguard/data
     restart: unless-stopped
 
-  # Development: Only Presidio (for local Bun with hot-reload)
-  presidio:
+  # Development only: the PII detector on its own, for `bun run dev`.
+  # Builds the shared `detector` stage of docker/Dockerfile (no duplicate
+  # Dockerfile). Point the local proxy at it with DETECTOR_URL=http://localhost:5002.
+  detector:
     profiles: ["dev"]
-    image: ghcr.io/sgasser/pasteguard:${PASTEGUARD_TAG:-en}
     build:
       context: .
       dockerfile: docker/Dockerfile
-      args:
-        LANGUAGES: ${LANGUAGES:-en}
+      target: detector
     ports:
       - "5002:5002"
-    environment:
-      - START_APP=false
-      - PORT=5002
-      - WORKERS=1
-    healthcheck:
-      disable: true
     restart: unless-stopped
index f7494e2ce59148577a2834f53c2b3f11eedb41ce..bc14936cc9096cb79532f76711ae4b6ec4e1e4cc 100644 (file)
@@ -1,31 +1,22 @@
-# PasteGuard - All-in-One Image
-# Single container with Proxy + PII Detection
+# PasteGuard — Docker image (multi-stage, single source of truth)
 #
-# Build: docker build -f docker/Dockerfile --build-arg LANGUAGES=en -t pasteguard:en .
-# Run:   docker run -p 3000:3000 -v ./config.yaml:/pasteguard/config.yaml -v ./data:/pasteguard/data pasteguard:en
-
-ARG LANGUAGES="en"
-
-# =============================================================================
-# Stage 1: Generate Presidio configuration files
-# =============================================================================
-FROM python:3.11-slim AS generator
-
-WORKDIR /build
-
-RUN pip install --no-cache-dir pyyaml
-
-COPY docker/presidio/languages.yaml /build/
-COPY docker/presidio/generate-configs.py /build/
-
-ARG LANGUAGES
-RUN python generate-configs.py \
-    --languages="${LANGUAGES}" \
-    --registry=/build/languages.yaml \
-    --output=/output
+# Two build targets share ONE detector definition (the `detector` stage):
+#   * detector  — the PII detector alone (uvicorn on :5002). Used by the
+#                 docker-compose `detector` dev service so the proxy can run from
+#                 source against it.
+#                 Build: docker build -f docker/Dockerfile --target detector -t pasteguard-detector .
+#   * (default) — the all-in-one deployment image: Bun proxy + detector under
+#                 supervisord. This is the published artifact.
+#                 Build: docker build -f docker/Dockerfile -t pasteguard:latest .
+#
+# Run the all-in-one:
+#   docker run -p 3000:3000 \
+#     -v ./config.yaml:/pasteguard/config.yaml:ro \
+#     -v ./data:/pasteguard/data \
+#     pasteguard:latest
 
 # =============================================================================
-# Stage 2: Build Bun application
+# Stage: bun-builder — build the Bun application
 # =============================================================================
 FROM oven/bun:1-slim AS bun-builder
 
@@ -38,47 +29,58 @@ COPY src ./src
 COPY tsconfig.json ./
 
 # =============================================================================
-# Stage 3: Final combined image
+# Stage: detector — the PII detector service (also a standalone build target)
 # =============================================================================
-FROM mcr.microsoft.com/presidio-analyzer:latest
+FROM python:3.11-slim AS detector
+
+# CPU-only torch. The CPU index serves both x86_64 and aarch64 CPU wheels, so
+# pin it for every arch. (PyPI's DEFAULT index now ships CUDA builds for
+# linux/arm64 too — e.g. torch 2.x+cuXXX — which would drag ~6 GB of unused
+# CUDA libraries into the image; the explicit CPU index avoids that.)
+RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
+
+# Install the detector package (pulls fastapi/uvicorn/gliner/stdnum/...).
+COPY detector/pyproject.toml /srv/detector/
+COPY detector/detector /srv/detector/detector
+RUN pip install --no-cache-dir /srv/detector
+
+# Bake the default model into a location the all-in-one runtime user (UID 1000)
+# can also read, and force offline use at runtime (no network needed for models).
+ENV DETECTOR_MODEL=urchade/gliner_multi_pii-v1
+ENV HF_HOME=/opt/models
+# Retry the fetch: under multi-arch (QEMU) builds a transient HF rate-limit or
+# network blip should not fail the whole image build.
+RUN ok=""; for i in 1 2 3; do \
+        python -c "import os; from gliner import GLiNER; GLiNER.from_pretrained(os.environ['DETECTOR_MODEL'])" && { ok=1; break; }; \
+        echo "model fetch attempt $i failed; retrying in 10s"; sleep 10; \
+    done; \
+    [ -n "$ok" ] && chown -R 1000:1000 /opt/models || exit 1
+ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
+
+EXPOSE 5002
+HEALTHCHECK --interval=10s --timeout=3s --start-period=40s --retries=5 \
+    CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5002/health').status==200 else 1)"
+
+CMD ["uvicorn", "detector.app:app", "--host", "0.0.0.0", "--port", "5002"]
 
-USER root
-
-ARG LANGUAGES
+# =============================================================================
+# Stage: all-in-one — Bun proxy + detector under supervisord (default target)
+# =============================================================================
+FROM detector AS allinone
 
-# Install supervisor for process management
+# supervisor manages both processes; curl backs the health check.
 RUN apt-get update && apt-get install -y --no-install-recommends \
     supervisor \
     curl \
     && apt-get clean \
     && rm -rf /var/lib/apt/lists/*
 
-# Install Rust only if Japanese is included
-RUN if echo "${LANGUAGES}" | grep -q "ja"; then \
-        apt-get update && apt-get install -y --no-install-recommends build-essential \
-        && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
-        && apt-get clean \
-        && rm -rf /var/lib/apt/lists/*; \
-    fi
-ENV PATH="/root/.cargo/bin:${PATH}"
-
-# Copy Bun binary from official image (uses baseline build for x64 compatibility)
-# The official oven/bun images use baseline builds which only require SSE4.2,
-# supporting older/low-power x86_64 CPUs (e.g., Intel Atom C3558R) that lack AVX2.
-# See: https://github.com/sgasser/pasteguard/issues/70
+# Copy the Bun binary from the official image (baseline build for x86_64
+# compatibility on older CPUs, see https://github.com/sgasser/pasteguard/issues/70).
 COPY --from=bun-builder /usr/local/bin/bun /usr/local/bin/bun
 ENV PATH="/usr/local/bin:${PATH}"
 
-# Copy Presidio configuration
-COPY --from=generator /output/nlp-config.yaml /app/presidio_analyzer/conf/default.yaml
-COPY --from=generator /output/recognizers-config.yaml /app/presidio_analyzer/conf/default_recognizers.yaml
-COPY --from=generator /output/analyzer-config.yaml /app/presidio_analyzer/conf/default_analyzer.yaml
-
-# Install spaCy models
-COPY --from=generator /output/install-models.sh /tmp/
-RUN chmod +x /tmp/install-models.sh && /tmp/install-models.sh && rm /tmp/install-models.sh
-
-# Copy Bun application to /pasteguard (separate from Presidio's /app)
+# Copy the Bun application.
 WORKDIR /pasteguard
 COPY --from=bun-builder /app/node_modules ./node_modules
 COPY --from=bun-builder /app/src ./src
@@ -86,25 +88,21 @@ COPY --from=bun-builder /app/package.json ./
 COPY --from=bun-builder /app/tsconfig.json ./
 COPY config.example.yaml ./
 
-# Create data directory and set permissions for UID 1000 (matches most Linux users)
-RUN mkdir -p /pasteguard/data && chown -R 1000:1000 /pasteguard
+# Create a real UID-1000 user with a home dir. torch resolves its cache dir via
+# getpwuid() at import, which fails on a bare numeric USER with no passwd entry.
+RUN useradd --uid 1000 --create-home --home-dir /home/pasteguard pasteguard \
+    && mkdir -p /pasteguard/data && chown -R 1000:1000 /pasteguard
 
-# Copy supervisor configuration
 COPY docker/supervisord.conf /etc/supervisor/conf.d/pasteguard.conf
 
-# Switch to non-root user for runtime
 USER 1000
+ENV HOME=/home/pasteguard
 
-# Environment defaults
-ENV PRESIDIO_URL=http://localhost:5002
-ENV PORT=5002
-ENV WORKERS=1
-ENV START_APP=true
-ENV PASTEGUARD_LANGUAGES=${LANGUAGES}
+# The proxy talks to the in-container detector.
+ENV DETECTOR_URL=http://localhost:5002
 
 EXPOSE 3000
 
-# Health check
 HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
     CMD curl -f http://localhost:3000/health || exit 1
 
diff --git a/docker/presidio/generate-configs.py b/docker/presidio/generate-configs.py
deleted file mode 100644 (file)
index 6a12998..0000000
+++ /dev/null
@@ -1,288 +0,0 @@
-#!/usr/bin/env python3
-"""
-Generate Presidio configuration files from selected languages.
-
-Usage:
-    python generate-configs.py --languages=en,de --output=/output
-
-Reads from languages.yaml and generates:
-    - nlp-config.yaml
-    - recognizers-config.yaml
-    - analyzer-config.yaml
-    - install-models.sh
-"""
-
-import argparse
-import sys
-from pathlib import Path
-
-import yaml
-
-
-def load_registry(registry_path: Path) -> dict:
-    """Load the language registry."""
-    with open(registry_path) as f:
-        return yaml.safe_load(f)
-
-
-def validate_languages(languages: list[str], registry: dict) -> list[str]:
-    """Validate requested languages exist in registry."""
-    available = set(registry["languages"].keys())
-    valid = []
-    invalid = []
-
-    for lang in languages:
-        if lang in available:
-            valid.append(lang)
-        else:
-            invalid.append(lang)
-
-    if invalid:
-        print(f"Error: Unknown language(s): {', '.join(invalid)}", file=sys.stderr)
-        print(f"Available: {', '.join(sorted(available))}", file=sys.stderr)
-        sys.exit(1)
-
-    return valid
-
-
-def generate_nlp_config(languages: list[str], registry: dict) -> dict:
-    """Generate nlp-config.yaml content."""
-    models = []
-    for lang in languages:
-        lang_config = registry["languages"][lang]
-        models.append({"lang_code": lang, "model_name": lang_config["model"]})
-
-    return {
-        "nlp_engine_name": "spacy",
-        "models": models,
-        "ner_model_configuration": {
-            "model_to_presidio_entity_mapping": {
-                # Standard labels (most languages)
-                "PER": "PERSON",
-                "PERSON": "PERSON",
-                "LOC": "LOCATION",
-                "GPE": "LOCATION",
-                "ORG": "ORGANIZATION",
-                # Polish (NKJP corpus)
-                "persName": "PERSON",
-                "placeName": "LOCATION",
-                "geogName": "LOCATION",
-                "orgName": "ORGANIZATION",
-                # Korean
-                "PS": "PERSON",
-                "LC": "LOCATION",
-                "OG": "ORGANIZATION",
-                # Swedish
-                "PRS": "PERSON",
-                # Norwegian
-                "GPE_LOC": "LOCATION",
-            },
-            "low_confidence_score_multiplier": 0.4,
-            "low_score_entity_names": ["ORG"],
-            "labels_to_ignore": [
-                "O",
-                "CARDINAL",
-                "EVENT",
-                "LANGUAGE",
-                "LAW",
-                "MONEY",
-                "ORDINAL",
-                "PERCENT",
-                "PRODUCT",
-                "QUANTITY",
-                "WORK_OF_ART",
-            ],
-        },
-    }
-
-
-def generate_analyzer_config(languages: list[str]) -> dict:
-    """Generate analyzer-config.yaml content."""
-    return {"supported_languages": languages, "default_score_threshold": 0}
-
-
-# Global recognizers - pattern-based, work for any language
-GLOBAL_RECOGNIZERS = [
-    "CreditCardRecognizer",
-    "CryptoRecognizer",
-    "DateRecognizer",
-    "EmailRecognizer",
-    "IbanRecognizer",
-    "IpRecognizer",
-    "UrlRecognizer",
-]
-
-# Language-specific recognizers - only loaded when that language is configured
-LANGUAGE_RECOGNIZERS = {
-    "en": [
-        # US
-        "UsSsnRecognizer",
-        "UsPassportRecognizer",
-        "UsItinRecognizer",
-        "UsBankRecognizer",
-        "UsLicenseRecognizer",
-        "MedicalLicenseRecognizer",
-        # UK
-        "UkNinoRecognizer",
-        "NhsRecognizer",
-    ],
-    "es": [
-        "EsNifRecognizer",
-        "EsNieRecognizer",
-    ],
-    "it": [
-        "ItDriverLicenseRecognizer",
-        "ItFiscalCodeRecognizer",
-        "ItVatCodeRecognizer",
-        "ItIdentityCardRecognizer",
-        "ItPassportRecognizer",
-    ],
-    "pl": [
-        "PlPeselRecognizer",
-    ],
-    "ko": [
-        "KrRrnRecognizer",
-    ],
-}
-
-
-def generate_recognizers_config(languages: list[str], registry: dict) -> dict:
-    """Generate recognizers-config.yaml content."""
-    all_langs = [{"language": lang} for lang in languages]
-
-    # Phone recognizer needs context words per language
-    phone_langs = []
-    for lang in languages:
-        lang_config = registry["languages"][lang]
-        entry = {"language": lang}
-        if "phone_context" in lang_config:
-            entry["context"] = lang_config["phone_context"]
-        phone_langs.append(entry)
-
-    recognizers = [
-        {
-            "name": "SpacyRecognizer",
-            "supported_languages": all_langs,
-            "type": "predefined",
-        },
-        {
-            "name": "PhoneRecognizer",
-            "supported_languages": phone_langs,
-            "type": "predefined",
-        },
-    ]
-
-    # Add global recognizers for all configured languages
-    for name in GLOBAL_RECOGNIZERS:
-        recognizers.append({
-            "name": name,
-            "supported_languages": all_langs,
-            "type": "predefined",
-        })
-
-    # Add language-specific recognizers only if that language is configured
-    for lang in languages:
-        if lang in LANGUAGE_RECOGNIZERS:
-            lang_entry = [{"language": lang}]
-            for name in LANGUAGE_RECOGNIZERS[lang]:
-                recognizers.append({
-                    "name": name,
-                    "supported_languages": lang_entry,
-                    "type": "predefined",
-                })
-
-    return {
-        "supported_languages": languages,
-        "global_regex_flags": 26,
-        "recognizers": recognizers,
-    }
-
-
-def generate_install_script(languages: list[str], registry: dict) -> str:
-    """Generate shell script to install spaCy models."""
-    version = registry["spacy_version"]
-    lines = ["#!/bin/sh", "set -e", ""]
-
-    for lang in languages:
-        model = registry["languages"][lang]["model"]
-        url = f"https://github.com/explosion/spacy-models/releases/download/{model}-{version}/{model}-{version}-py3-none-any.whl"
-        lines.append(f'echo "Installing {model} for {lang}..."')
-        # Use poetry run pip to install in the correct virtual environment
-        lines.append(f"poetry run pip install --no-cache-dir {url}")
-        lines.append("")
-
-    lines.append('echo "All models installed successfully"')
-    return "\n".join(lines)
-
-
-def write_yaml(data: dict, path: Path) -> None:
-    """Write data to YAML file."""
-    with open(path, "w") as f:
-        yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
-
-
-def main():
-    parser = argparse.ArgumentParser(description="Generate Presidio configs")
-    parser.add_argument(
-        "--languages",
-        required=True,
-        help="Comma-separated list of language codes (e.g., en,de,fr)",
-    )
-    parser.add_argument(
-        "--registry",
-        default="/build/languages.yaml",
-        help="Path to languages.yaml registry",
-    )
-    parser.add_argument(
-        "--output", default="/output", help="Output directory for generated files"
-    )
-    args = parser.parse_args()
-
-    # Parse languages
-    languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
-    if not languages:
-        print("Error: No languages specified", file=sys.stderr)
-        sys.exit(1)
-
-    # Load registry
-    registry_path = Path(args.registry)
-    if not registry_path.exists():
-        print(f"Error: Registry not found: {registry_path}", file=sys.stderr)
-        sys.exit(1)
-
-    registry = load_registry(registry_path)
-
-    # Validate languages
-    languages = validate_languages(languages, registry)
-
-    # Create output directory
-    output_dir = Path(args.output)
-    output_dir.mkdir(parents=True, exist_ok=True)
-
-    # Generate configs
-    print(f"Generating configs for: {', '.join(languages)}")
-
-    nlp_config = generate_nlp_config(languages, registry)
-    write_yaml(nlp_config, output_dir / "nlp-config.yaml")
-    print(f"  - nlp-config.yaml")
-
-    analyzer_config = generate_analyzer_config(languages)
-    write_yaml(analyzer_config, output_dir / "analyzer-config.yaml")
-    print(f"  - analyzer-config.yaml")
-
-    recognizers_config = generate_recognizers_config(languages, registry)
-    write_yaml(recognizers_config, output_dir / "recognizers-config.yaml")
-    print(f"  - recognizers-config.yaml")
-
-    install_script = generate_install_script(languages, registry)
-    install_path = output_dir / "install-models.sh"
-    with open(install_path, "w") as f:
-        f.write(install_script)
-    install_path.chmod(0o755)
-    print(f"  - install-models.sh")
-
-    print("Done!")
-
-
-if __name__ == "__main__":
-    main()
diff --git a/docker/presidio/languages.yaml b/docker/presidio/languages.yaml
deleted file mode 100644 (file)
index 750707e..0000000
+++ /dev/null
@@ -1,155 +0,0 @@
-# PasteGuard Language Registry
-# 24 spaCy languages with large models for accurate PII detection
-#
-# Usage: LANGUAGES=en,de docker compose build
-
-spacy_version: "3.8.0"
-
-# Phone context words per language (5-7 words each)
-# Covers: phone/telephone, number, mobile, call
-# Based on research of common usage and regional variants
-
-languages:
-  # Catalan
-  ca:
-    name: Catalan
-    model: ca_core_news_lg
-    phone_context: [telèfon, número, mòbil, trucada, trucar]
-
-  # Chinese
-  zh:
-    name: Chinese
-    model: zh_core_web_lg
-    phone_context: [电话, 手机, 号码, 打电话, 电话号码, 手机号码]
-
-  # Croatian
-  hr:
-    name: Croatian
-    model: hr_core_news_lg
-    phone_context: [telefon, broj, mobitel, poziv, nazovi, zvati]
-
-  # Danish
-  da:
-    name: Danish
-    model: da_core_news_lg
-    phone_context: [telefon, nummer, mobil, mobiltelefon, opkald, ringe]
-
-  # Dutch
-  nl:
-    name: Dutch
-    model: nl_core_news_lg
-    phone_context: [telefoon, nummer, mobiel, mobieltje, GSM, bellen]
-
-  # English (Presidio defaults)
-  en:
-    name: English
-    model: en_core_web_lg
-    phone_context: [phone, number, telephone, cell, cellphone, mobile, call]
-
-  # Finnish
-  fi:
-    name: Finnish
-    model: fi_core_news_lg
-    phone_context: [puhelin, numero, kännykkä, matkapuhelin, soittaa, puhelinnumero]
-
-  # French
-  fr:
-    name: French
-    model: fr_core_news_lg
-    phone_context: [téléphone, numéro, portable, mobile, appeler, tél]
-
-  # German
-  de:
-    name: German
-    model: de_core_news_lg
-    phone_context: [telefon, nummer, handy, mobiltelefon, anruf, rufnummer]
-
-  # Greek
-  el:
-    name: Greek
-    model: el_core_news_lg
-    phone_context: [τηλέφωνο, αριθμός, κινητό, κλήση, τηλεφωνώ, καλώ]
-
-  # Italian
-  it:
-    name: Italian
-    model: it_core_news_lg
-    phone_context: [telefono, numero, cellulare, telefonino, chiamare, chiamata]
-
-  # Japanese
-  ja:
-    name: Japanese
-    model: ja_core_news_lg
-    phone_context: [電話, 携帯, 番号, スマホ, ケータイ, 電話番号]
-
-  # Korean
-  ko:
-    name: Korean
-    model: ko_core_news_lg
-    phone_context: [전화, 휴대폰, 핸드폰, 번호, 전화번호, 통화]
-
-  # Lithuanian
-  lt:
-    name: Lithuanian
-    model: lt_core_news_lg
-    phone_context: [telefonas, numeris, mobilusis, skambutis, skambinti]
-
-  # Macedonian
-  mk:
-    name: Macedonian
-    model: mk_core_news_lg
-    phone_context: [телефон, број, мобилен, повик, звони]
-
-  # Norwegian Bokmål
-  nb:
-    name: Norwegian
-    model: nb_core_news_lg
-    phone_context: [telefon, nummer, mobil, mobiltelefon, samtale, ringe]
-
-  # Polish
-  pl:
-    name: Polish
-    model: pl_core_news_lg
-    phone_context: [telefon, numer, komórka, komórkowy, dzwoń, zadzwoń]
-
-  # Portuguese
-  pt:
-    name: Portuguese
-    model: pt_core_news_lg
-    phone_context: [telefone, número, celular, telemóvel, ligar, telefonar]
-
-  # Romanian
-  ro:
-    name: Romanian
-    model: ro_core_news_lg
-    phone_context: [telefon, număr, mobil, apel, suna]
-
-  # Russian
-  ru:
-    name: Russian
-    model: ru_core_news_lg
-    phone_context: [телефон, номер, мобильник, мобила, сотовый, звонок]
-
-  # Slovenian
-  sl:
-    name: Slovenian
-    model: sl_core_news_lg
-    phone_context: [telefon, številka, mobilnik, mobilec, klic, pokliči]
-
-  # Spanish
-  es:
-    name: Spanish
-    model: es_core_news_lg
-    phone_context: [teléfono, número, móvil, celular, llamar, llamada]
-
-  # Swedish
-  sv:
-    name: Swedish
-    model: sv_core_news_lg
-    phone_context: [telefon, nummer, mobil, mobiltelefon, samtal, ringa]
-
-  # Ukrainian
-  uk:
-    name: Ukrainian
-    model: uk_core_news_lg
-    phone_context: [телефон, номер, мобільний, мобілка, дзвінок, дзвони]
index 466f73a63dc575b49e36c03be985c8b2c6ae7428..eeaed187955a951fd5d0ea7159ccd253af9987df 100644 (file)
@@ -5,9 +5,8 @@ logfile_maxbytes=0
 pidfile=/tmp/supervisord.pid
 loglevel=info
 
-[program:presidio]
-command=poetry run gunicorn -w %(ENV_WORKERS)s -b 0.0.0.0:%(ENV_PORT)s --timeout 300 --preload "app:create_app()"
-directory=/app
+[program:detector]
+command=uvicorn detector.app:app --host 127.0.0.1 --port 5002
 autostart=true
 autorestart=true
 startsecs=10
@@ -21,7 +20,7 @@ priority=10
 [program:pasteguard]
 command=/usr/local/bin/bun run src/index.ts
 directory=/pasteguard
-autostart=%(ENV_START_APP)s
+autostart=true
 autorestart=true
 startsecs=5
 startretries=3
index 99ae11b1ff8cb211d9b189b8d78466a104298eb0..0cac87f9ca8add340bd9696ef32495512dfc22e6 100644 (file)
@@ -190,7 +190,7 @@ const unmasked = unmask(llmResponse, context);
 
 ### Detection Error (503)
 
-Returned when Presidio or secrets detection is unavailable:
+Returned when the PII detector or secrets detection is unavailable:
 
 ```json
 {
@@ -198,7 +198,7 @@ Returned when Presidio or secrets detection is unavailable:
     "message": "PII detection failed",
     "type": "detection_error",
     "details": [
-      { "message": "Failed to connect to Presidio..." }
+      { "message": "Failed to connect to the PII detector..." }
     ]
   }
 }
index cbcead3d5bfa4b8d04296a67a8723796a319d5d3..2bde66e97817ae640e1bf06010abcd21a8615722 100644 (file)
@@ -25,7 +25,7 @@ Returns HTTP 200 when healthy, HTTP 503 when degraded.
 {
   "status": "healthy",
   "services": {
-    "presidio": "up"
+    "detector": "up"
   },
   "timestamp": "2026-01-15T10:30:00Z"
 }
@@ -37,7 +37,7 @@ When a service is down:
 {
   "status": "degraded",
   "services": {
-    "presidio": "down"
+    "detector": "down"
   },
   "timestamp": "2026-01-15T10:30:00Z"
 }
@@ -103,18 +103,4 @@ curl http://localhost:3000/info
 }
 ```
 
-When language validation is available, `languages` becomes an object:
-
-```json
-{
-  "pii_detection": {
-    "languages": {
-      "configured": ["en", "de", "fr"],
-      "available": ["en", "de"],
-      "missing": ["fr"]
-    }
-  }
-}
-```
-
 In route mode, `local` provider info is also included.
index 30d38eeea3268fe50104fc19a27f324bc60f75d4..dc31ca54653b29559e85a919bb371bdd655faaff 100644 (file)
@@ -1,59 +1,47 @@
 ---
 title: PII Detection
-description: Personal data detection powered by Microsoft Presidio
+description: Personal data detection via a deterministic checksum layer plus multilingual GLiNER NER
 ---
 
-PasteGuard uses Microsoft Presidio for PII detection, supporting 24 languages with automatic language detection.
+PasteGuard detects PII with a small open-source service that pairs a deterministic regex/checksum layer with a multilingual neural model. Detection is **language-agnostic**: the neural model finds names and places whatever the request language — an Italian name and city in a German letter are caught even with a `de` hint — and structured identifiers (IBAN, credit card, email, IP) are matched by checksum, not by language.
+
+## How it works
+
+- **Deterministic layer** — regex candidates validated by checksum/format (`python-stdnum`, `phonenumbers`). Owns the structured identifiers; every match is validated, not guessed, and scores `1.0`.
+- **Neural layer** — multilingual [GLiNER](https://github.com/urchade/GLiNER) NER for names and locations, in one pass over the text.
+
+The detector speaks the `/analyze` HTTP contract, is configured through the `detector_url` setting, and runs as a separate service (see [`detector/`](https://github.com/sgasser/pasteguard/tree/main/detector)).
 
 ## Supported Entities
 
 | Entity | Examples |
 |--------|----------|
 | `PERSON` | Dr. Sarah Chen, John Smith |
+| `LOCATION` | New York, München, Milano |
 | `EMAIL_ADDRESS` | sarah.chen@hospital.org |
-| `PHONE_NUMBER` | +1-555-123-4567 |
-| `CREDIT_CARD` | 4111-1111-1111-1111 |
+| `PHONE_NUMBER` | +49 171 1234567 |
 | `IBAN_CODE` | DE89 3704 0044 0532 0130 00 |
+| `CREDIT_CARD` | 4111 1111 1111 1111 |
 | `IP_ADDRESS` | 192.168.1.1 |
-| `LOCATION` | New York, 123 Main St |
-| `US_SSN` | 123-45-6789 |
-| `US_PASSPORT` | 123456789 |
-| `CRYPTO` | Bitcoin addresses |
-| `URL` | https://example.com |
-
-## Language Support
-
-PasteGuard supports 24 languages. The language is auto-detected from your input text.
-
-**Available languages:** Catalan, Chinese, Croatian, Danish, Dutch, English, Finnish, French, German, Greek, Italian, Japanese, Korean, Lithuanian, Macedonian, Norwegian, Polish, Portuguese, Romanian, Russian, Slovenian, Spanish, Swedish, Ukrainian
+| `VAT_CODE` | EU VAT number, e.g. `DE136695976`, `IT00743110157`, `FR40303265045` |
 
-### Docker Images
+Names and locations are detected by the multilingual model (verified across EN/DE/IT/FR/ES/NL/PT and more). IBAN, credit card, phone, email, and IP work internationally. `VAT_CODE` covers any EU member state, validated per-country via the VAT checksum (`python-stdnum`) and requiring the country prefix. The structured identifiers (IBAN, credit card, IP, VAT) are checksum- or format-validated, so they are caught with high precision and without false positives on lookalike numbers.
 
-Languages are auto-configured per image — no config changes needed:
+## Languages
 
-- **`:en` image** → English only
-- **`:eu` image** → English, German, Spanish, French, Italian, Dutch, Polish, Portuguese, Romanian
-
-For custom languages, build locally:
-
-```bash
-LANGUAGES=en,de,ja docker compose up -d --build
-```
-
-If only one language is configured, language detection is skipped for better performance.
+Detection is multilingual and **language-agnostic** — one pass over the text finds entities regardless of the language it is written in, so mixed-language documents (for example an Italian name and city in a German letter) are handled correctly. The `language` field in a request is used only as a hint, e.g. to pick a phone-number region; it does not gate detection.
 
 ## Confidence Scoring
 
-Each detected entity has a confidence score (0.0 - 1.0). The default threshold is 0.7.
-
-- **Higher threshold** = fewer false positives, might miss some PII
-- **Lower threshold** = catches more PII, more false positives
+Neural detections carry a confidence score (0.0–1.0); checksum-validated identifiers are always `1.0`. The default request threshold is `0.7`:
 
 ```yaml
 pii_detection:
   score_threshold: 0.7
 ```
 
+Each neural label also has a calibrated floor (person, location) so high-precision labels and faint-but-important ones can coexist. Tune them per deployment via the `DETECTOR_FLOOR_PERSON` and `DETECTOR_FLOOR_LOCATION` environment variables on the detector.
+
 ## Response Headers
 
 When PII is detected:
index 4fe6a442b2f855c81407615ce4490a1a6f3a76a0..674a55cca3a4726114ef8f83d234652d5ca70b0f 100644 (file)
@@ -65,5 +65,5 @@ providers:
     api_key: ${OPENAI_API_KEY}
 
 pii_detection:
-  presidio_url: ${PRESIDIO_URL:-http://localhost:5002}
+  detector_url: ${DETECTOR_URL:-http://localhost:5002}
 ```
index ee7761c955dafb67feaa5bd8b7c558553aa03d41..7961e1ade000974248a22858ecb735e563716c52 100644 (file)
@@ -5,70 +5,49 @@ description: Configure PII detection settings
 
 ```yaml
 pii_detection:
-  presidio_url: http://localhost:5002
-  languages: ${PASTEGUARD_LANGUAGES:-en}  # Auto-configured per Docker image
+  detector_url: http://localhost:5002
+  languages:
+    - en
+    - de
+    - it
   fallback_language: en
   score_threshold: 0.7
   entities:
     - PERSON
+    - LOCATION
     - EMAIL_ADDRESS
     - PHONE_NUMBER
     - CREDIT_CARD
     - IBAN_CODE
     - IP_ADDRESS
-    - LOCATION
+    - VAT_CODE
 ```
 
 ## Options
 
 | Option | Default | Description |
 |--------|---------|-------------|
-| `presidio_url` | `http://localhost:5002` | Presidio analyzer URL |
-| `languages` | (per image) | Languages to detect. Auto-configured in Docker images |
-| `fallback_language` | `en` | Fallback if detected language not in list |
-| `score_threshold` | `0.7` | Minimum confidence (0.0-1.0) |
-| `entities` | See below | Entity types to detect |
+| `detector_url` | `http://localhost:5002` | Detector `/analyze` URL |
+| `languages` | `["en"]` | Languages the proxy auto-detects among (used as a hint, e.g. phone region) |
+| `fallback_language` | `en` | Language hint used when none is detected |
+| `score_threshold` | `0.7` | Minimum confidence floor for the neural labels PERSON and LOCATION (0.0-1.0). Checksum-validated identifiers always score `1.0` and are unaffected |
+| `entities` | See below | Entity types to return |
 
 ## Languages
 
-Languages are auto-configured per Docker image:
-
-- **`:en` image** → English only
-- **`:eu` image** → English, German, Spanish, French, Italian, Dutch, Polish, Portuguese, Romanian
-
-Each language adds ~10s to startup time as spaCy models are loaded.
-
-For custom language builds:
-
-```bash
-LANGUAGES=en,de,ja docker compose up -d --build
-```
-
-Available languages (24):
-`ca`, `zh`, `hr`, `da`, `nl`, `en`, `fi`, `fr`, `de`, `el`, `it`, `ja`, `ko`, `lt`, `mk`, `nb`, `pl`, `pt`, `ro`, `ru`, `sl`, `es`, `sv`, `uk`
+Detection is **multilingual and language-agnostic** — the model finds names and places regardless of the language the text is written in (an Italian name and city in a German letter are caught), and structured identifiers are matched by checksum independent of language. There are no per-language images and no spaCy models to load.
 
-### Override Languages
-
-For local development or custom setups, override via config:
+The `languages` list and `fallback_language` only control the language *hint* the proxy sends with each request (which the detector uses for things like phone-number region); they do not gate detection.
 
 ```yaml
 pii_detection:
   languages:
     - en
     - de
+    - it
+  fallback_language: de
 ```
 
-### Fallback Language
-
-If the detected language isn't in your configured list, the fallback is used:
-
-```yaml
-pii_detection:
-  fallback_language: en  # Used for unsupported languages
-```
-
-### Performance
-
 If only one language is configured, language detection is skipped for better performance.
 
 ## Entities
@@ -76,28 +55,36 @@ If only one language is configured, language detection is skipped for better per
 | Entity | Examples |
 |--------|----------|
 | `PERSON` | Dr. Sarah Chen, John Smith |
+| `LOCATION` | New York, München |
 | `EMAIL_ADDRESS` | sarah.chen@hospital.org |
-| `PHONE_NUMBER` | +1-555-123-4567 |
-| `CREDIT_CARD` | 4111-1111-1111-1111 |
+| `PHONE_NUMBER` | +49 171 1234567 |
+| `CREDIT_CARD` | 4111 1111 1111 1111 |
 | `IBAN_CODE` | DE89 3704 0044 0532 0130 00 |
 | `IP_ADDRESS` | 192.168.1.1 |
-| `LOCATION` | New York, 123 Main St |
-| `US_SSN` | 123-45-6789 |
-| `US_PASSPORT` | 123456789 |
-| `CRYPTO` | Bitcoin addresses |
-| `URL` | https://example.com |
+| `VAT_CODE` | EU VAT number, e.g. `DE136695976`, `IT00743110157`, `FR40303265045` |
+
+Names and locations are multilingual. IBAN, credit card, phone, email, and IP are international. `VAT_CODE` covers any EU member state — validated per-country via the VAT checksum (`python-stdnum`) and requiring the country prefix; a bare domestic number without it is not flagged. The structured identifiers (IBAN, credit card, IP, VAT) are checksum- or format-validated, so they are caught with high precision and without false positives on lookalike numbers.
 
 ## Score Threshold
 
-Higher = fewer false positives, might miss some PII. Lower = catches more PII, more false positives.
+`score_threshold` raises the confidence floor for the **tunable** neural labels
+**PERSON** and **LOCATION** only. Higher = fewer false positives, might miss
+some PII; lower = catches more, more false positives.
 
 ```yaml
 pii_detection:
   score_threshold: 0.7  # Default, good balance
   # score_threshold: 0.5  # More aggressive
-  # score_threshold: 0.9  # More conservative
+  # score_threshold: 0.9  # More conservative (PERSON/LOCATION)
 ```
 
+Checksum-validated identifiers are always reported (score `1.0`) and are never
+dropped by the threshold. Tune the neural labels per deployment via the
+`DETECTOR_FLOOR_PERSON`, `DETECTOR_FLOOR_LOCATION`, and `DETECTOR_FLOOR_ADDRESS`
+environment variables on the detector service — e.g. `DETECTOR_FLOOR_PERSON=0.9`
+(fewer person false positives) or `DETECTOR_FLOOR_LOCATION=0.4` (more location
+recall). Street addresses are detected by the model and reported as `LOCATION`.
+
 ## Whitelist
 
 Exclude specific text patterns from PII masking. Useful for preventing false positives on company names or product identifiers.
@@ -131,4 +118,4 @@ pii_detection:
 | `tool` | Tool/function call results |
 | `function` | Legacy function results (OpenAI) |
 
-This reduces Presidio API calls for large system prompts and avoids false positives on app-controlled content.
+This reduces detector calls for large system prompts and avoids false positives on app-controlled content.
index 339892efa8c14ca92b96f4a6f965e3afb2d32b68..42487ecc67153df4f011cfb0110d96927a69c9ff 100644 (file)
@@ -1,40 +1,40 @@
 ---
 title: Installation
-description: Docker images and deployment options
+description: Docker image and deployment options
 ---
 
-PasteGuard provides prebuilt Docker images for quick deployment. No build step required.
+PasteGuard ships as a single all-in-one Docker image — the Bun proxy and the PII
+detector (deterministic regex/checksum + multilingual GLiNER) run together in one
+container. No build step required.
 
 ## Docker Image
 
-PasteGuard is a single all-in-one container that includes both the proxy and PII detection:
-
 ```
 ghcr.io/sgasser/pasteguard
 ```
 
-| Tag | Languages | Size | Use Case |
-|-----|-----------|------|----------|
-| `en` / `latest` | English | ~2.7GB | Default, English-only teams |
-| `eu` | en, de, es, fr, it, nl, pl, pt, ro | ~12GB | European businesses |
+| Tag | Description |
+|-----|-------------|
+| `latest` | Latest release |
+| `vX.Y.Z` | Pinned release version |
+
+Detection is multilingual and language-agnostic — one model finds entities
+regardless of the language the text is written in, so there are no per-language
+image variants.
 
 ## Quick Start
 
 ```bash
-docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:en
+docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:latest
 ```
 
-Dashboard: [http://localhost:3000/dashboard](http://localhost:3000/dashboard)
-
-## European Languages
-
-For German, Spanish, French, Italian, Dutch, Polish, Portuguese, and Romanian:
-
-```bash
-docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:eu
-```
+PasteGuard runs on `http://localhost:3000`. Open
+[http://localhost:3000/dashboard](http://localhost:3000/dashboard) for the dashboard.
 
-Languages are auto-configured per image — no config changes needed. The EU image automatically enables all 9 European languages.
+<Note>
+The first start loads the detection model and can take a little longer; the
+container reports healthy once both the proxy and the detector are ready.
+</Note>
 
 ## Production Setup
 
@@ -52,55 +52,33 @@ mkdir -p data
 docker run -d --name pasteguard --restart unless-stopped -p 3000:3000 \
   -v ./config.yaml:/pasteguard/config.yaml:ro \
   -v ./data:/pasteguard/data \
-  ghcr.io/sgasser/pasteguard:en
+  ghcr.io/sgasser/pasteguard:latest
 ```
 
 This gives you:
 - **Custom config** — edit `config.yaml` to change detection settings
 - **Persistent logs** — request history in `data/pasteguard.db` survives restarts
-- **Auto-restart** — container restarts automatically
+- **Auto-restart** — the container restarts automatically
 
-## Custom Language Builds
-
-For languages not in prebuilt images (Nordic, Asian, Eastern European), clone and build:
+Or with Docker Compose:
 
 ```bash
-git clone https://github.com/sgasser/pasteguard.git
-cd pasteguard
-LANGUAGES=en,de,ja docker compose up -d --build
+curl -O https://raw.githubusercontent.com/sgasser/pasteguard/main/docker-compose.yml
+docker compose up -d
 ```
 
-### Available Languages (24)
-
-| Code | Language | Code | Language |
-|------|----------|------|----------|
-| `en` | English | `ja` | Japanese |
-| `de` | German | `ko` | Korean |
-| `fr` | French | `zh` | Chinese |
-| `es` | Spanish | `sv` | Swedish |
-| `it` | Italian | `da` | Danish |
-| `nl` | Dutch | `nb` | Norwegian |
-| `pt` | Portuguese | `fi` | Finnish |
-| `pl` | Polish | `el` | Greek |
-| `ru` | Russian | `ro` | Romanian |
-| `uk` | Ukrainian | `hr` | Croatian |
-| `ca` | Catalan | `sl` | Slovenian |
-| `lt` | Lithuanian | `mk` | Macedonian |
-
-## Environment Variables
+## Languages
 
-**Runtime (docker run):**
+Detection is multilingual and needs no per-language configuration. The `languages`
+and `fallback_language` settings only set a hint sent with each request (e.g. for
+phone-number region) — see [PII Detection Config](/configuration/pii-detection).
 
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PASTEGUARD_LANGUAGES` | (per image) | Override enabled languages at runtime |
-
-**Build-time (docker compose build):**
+## Environment Variables
 
 | Variable | Default | Description |
 |----------|---------|-------------|
-| `PASTEGUARD_TAG` | `en` | Image tag for docker compose |
-| `LANGUAGES` | `en` | Languages to include when building locally |
+| `DETECTOR_URL` | `http://localhost:5002` | Where the proxy reaches the detector. In the all-in-one image this is in-container and rarely changed; override it to point at an external detector. |
+| `PASTEGUARD_STARTUP_TIMEOUT` | `180` | Seconds to wait for the detector to become ready at startup |
 
 ## Next Steps
 
index 017f49def8d5b064e78e77feeac92ab963e237c8..4867916a2b441830ac34886d7cf636d1f2e9805d 100644 (file)
@@ -5,7 +5,7 @@ description: AI gets the context. Not your secrets. Open-source privacy proxy fo
 
 PasteGuard automatically hides names, emails, and API keys before you send prompts to AI. Your data never leaves your machine.
 
-Detects 30+ types of sensitive data across 24 languages.
+Detects personal data and secrets in many languages.
 
 <Frame>
   <img className="block dark:hidden" src="/images/comparison-light.png" alt="PasteGuard Comparison" />
index 2b0fce3612f4330f500fdbc27a69914b1172ebad..15653af60caa37c01df11d0fd655307296ed31c0 100644 (file)
@@ -6,13 +6,13 @@ description: Run PasteGuard as a local proxy
 ## 1. Start PasteGuard
 
 ```bash
-docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:en
+docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:latest
 ```
 
 PasteGuard runs on `http://localhost:3000`. Open `http://localhost:3000/dashboard` to see the dashboard.
 
 <Note>
-For custom configuration, European languages, or persistent logs, see [Installation](/installation).
+For custom configuration or persistent logs, see [Installation](/installation).
 </Note>
 
 ## 2. API Endpoints
index d4ea71753463abe03bbb2b4f8d7312d4af5598ee..add7fdc3eedfc7679de9d44f50ec9cac5cb36ae9 100644 (file)
@@ -26,7 +26,7 @@ Placeholder numbering stays consistent across messages in the same conversation.
 The extension needs PasteGuard running locally:
 
 ```bash
-docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:en
+docker run --rm -p 3000:3000 ghcr.io/sgasser/pasteguard:latest
 ```
 
 See [Quickstart](/quickstart) for setup details.
index c85a368221db47c2bb70ece2587d1922dfcdaa55..ee710d8f5dd37144e310fd034a9cedc05139d4cc 100644 (file)
@@ -10,7 +10,7 @@ PasteGuard protects your codebase when you use AI coding assistants. Config file
 - **Secrets** — API keys, private keys, database passwords, tokens in config files and `.env` files
 - **PII** — Customer names, emails, phone numbers in code comments, test data, or log output
 
-PII detection supports [24 languages](/installation). Full entity lists: [PII Detection](/concepts/pii-detection), [Secrets Detection](/concepts/secrets-detection).
+PII detection works [in many languages](/installation). Full entity lists: [PII Detection](/concepts/pii-detection), [Secrets Detection](/concepts/secrets-detection).
 
 ## Claude Code
 
index 25e0e9c9c1bcd2b2632df15ab5be1804119abfe8..c2dfa79f7189614d18d0a6f22c12dbff737935fc 100644 (file)
@@ -1,6 +1,6 @@
 {
   "name": "pasteguard",
-  "version": "0.4.2",
+  "version": "0.5.0",
   "description": "Privacy proxy for LLMs. Masks personal data and secrets before sending to your provider.",
   "type": "module",
   "main": "src/index.ts",
index 3a627f6a70f816047a99eacd0eac21658ee38a11..1d53c2cba0296a688e7bc3625101737aa7651db8 100644 (file)
@@ -23,7 +23,7 @@ providers:
   openai: {}
   anthropic: {}
 pii_detection:
-  presidio_url: http://localhost:5002
+  detector_url: http://localhost:5002
 `);
 
     try {
@@ -44,7 +44,7 @@ providers:
   codex:
     base_url: http://localhost:4000/codex
 pii_detection:
-  presidio_url: http://localhost:5002
+  detector_url: http://localhost:5002
 `);
 
     try {
index 3791118f11354a65d5c6721cd702a41f7d7f02f9..19f0642e8011b229e08d0acf497d932567af5a1b 100644 (file)
@@ -43,8 +43,8 @@ const MaskingSchema = z.object({
 
 const LanguageEnum = z.enum(SUPPORTED_LANGUAGES);
 
-// Accept either array or comma-separated string for languages
-// This allows using env vars like PASTEGUARD_LANGUAGES=en,de,fr
+// Accept either an array or a comma-separated string for languages (the latter
+// supports ${ENV_VAR} substitution in the YAML config, e.g. languages: ${LANGS:-en}).
 const LanguagesSchema = z
   .union([z.array(LanguageEnum), z.string()])
   .transform((val) => {
@@ -56,7 +56,7 @@ const LanguagesSchema = z
 
 const PIIDetectionSchema = z.object({
   enabled: z.boolean().default(true),
-  presidio_url: z.string().url(),
+  detector_url: z.string().url(),
   languages: LanguagesSchema,
   fallback_language: LanguageEnum.default("en"),
   score_threshold: z.coerce.number().min(0).max(1).default(0.7),
@@ -64,12 +64,13 @@ const PIIDetectionSchema = z.object({
     .array(z.string())
     .default([
       "PERSON",
+      "LOCATION",
       "EMAIL_ADDRESS",
       "PHONE_NUMBER",
       "CREDIT_CARD",
       "IBAN_CODE",
       "IP_ADDRESS",
-      "LOCATION",
+      "VAT_CODE",
     ]),
   scan_roles: z.array(z.string()).optional(),
 });
index 56b214fcb00f7ef2897ea77fe7f5115510835c50..7e30f428696544339b77e703b240b6b6b1494081 100644 (file)
@@ -1,6 +1,6 @@
 /**
- * All 24 spaCy languages with trained pipelines
- * See docker/presidio/languages.yaml for full list
+ * Languages the proxy can detect and send as a hint to the detector.
+ * Detection itself is language-agnostic; this list only gates the hint.
  */
 export const SUPPORTED_LANGUAGES = [
   "ca", // Catalan
index 79cf5fd9b6c01849c6a1789e4e7240cb6bf0d631..6ef1d2b9d154013ae861b68638712a84b9e03c87 100644 (file)
@@ -111,44 +111,22 @@ async function validateStartup() {
 
   const detector = getPIIDetector();
 
-  // Wait for Presidio to be ready (multi-language setups need longer to load spaCy models)
+  // Wait for the detector to be ready (model load can take a while on first start)
   const startupTimeout = Number(process.env.PASTEGUARD_STARTUP_TIMEOUT) || 180;
-  console.log("[STARTUP] Connecting to Presidio...");
+  console.log("[STARTUP] Connecting to the detector...");
   const ready = await detector.waitForReady(startupTimeout, 1000);
 
   if (!ready) {
     console.error(
-      `[STARTUP] ✗ Could not connect to Presidio at ${config.pii_detection.presidio_url}`,
-    );
-    console.error(
-      "          Make sure Presidio is running: docker compose up presidio-analyzer -d",
+      `[STARTUP] ✗ Could not connect to the detector at ${config.pii_detection.detector_url}`,
     );
+    console.error("          Make sure the detector is running: docker compose up detector -d");
     process.exit(1);
   }
 
-  console.log("[STARTUP] ✓ Presidio connected");
-
-  // Validate configured languages
-  console.log(`[STARTUP] Validating languages: ${config.pii_detection.languages.join(", ")}`);
-  const validation = await detector.validateLanguages(config.pii_detection.languages);
-
-  if (validation.missing.length > 0) {
-    console.error("\n❌ Language mismatch detected!\n");
-    console.error(`   Configured: ${config.pii_detection.languages.join(", ")}`);
-    console.error(
-      `   Available:  ${validation.available.length > 0 ? validation.available.join(", ") : "(none)"}`,
-    );
-    console.error(`   Missing:    ${validation.missing.join(", ")}\n`);
-    console.error("   To fix, either:");
-    console.error(
-      `   1. Rebuild: LANGUAGES=${config.pii_detection.languages.join(",")} docker compose build presidio-analyzer`,
-    );
-    console.error(`   2. Update config.yaml languages to: [${validation.available.join(", ")}]\n`);
-    console.error("[STARTUP] ✗ Language configuration mismatch. Exiting for safety.");
-    process.exit(1);
-  } else {
-    console.log("[STARTUP] ✓ All configured languages available");
-  }
+  console.log("[STARTUP] ✓ Detector connected");
+  // Language config is validated at load time (config.ts LanguageEnum); the
+  // detector is language-agnostic, so there is no runtime language gate.
 }
 
 function printStartupBanner(config: ReturnType<typeof getConfig>, host: string, port: number) {
index 0e6003040d8fd969b5add6cc377b8e5a737ee513..8629ef5ae4aa76de7a7bebc1c51320d723d80e0c 100644 (file)
@@ -5,7 +5,7 @@ import { filterWhitelistedEntities, PIIDetector } from "./detect";
 
 const originalFetch = globalThis.fetch;
 
-function mockPresidio(
+function mockDetector(
   responses: Record<
     string,
     Array<{ entity_type: string; start: number; end: number; score: number }>
@@ -52,7 +52,7 @@ describe("PIIDetector", () => {
 
   describe("analyzeRequest", () => {
     test("scans all message roles", async () => {
-      mockPresidio({
+      mockDetector({
         "system-pii": [{ entity_type: "PERSON", start: 0, end: 10, score: 0.9 }],
         "user-pii": [{ entity_type: "EMAIL_ADDRESS", start: 0, end: 8, score: 0.9 }],
         "assistant-pii": [{ entity_type: "PHONE_NUMBER", start: 0, end: 13, score: 0.9 }],
@@ -75,7 +75,7 @@ describe("PIIDetector", () => {
     });
 
     test("detects PII in system message when user message has none", async () => {
-      mockPresidio({
+      mockDetector({
         "John Doe": [{ entity_type: "PERSON", start: 18, end: 26, score: 0.95 }],
       });
 
@@ -93,7 +93,7 @@ describe("PIIDetector", () => {
     });
 
     test("detects PII in earlier user message", async () => {
-      mockPresidio({
+      mockDetector({
         "secret@email.com": [{ entity_type: "EMAIL_ADDRESS", start: 12, end: 28, score: 0.99 }],
       });
 
@@ -111,7 +111,7 @@ describe("PIIDetector", () => {
     });
 
     test("returns empty result for no messages", async () => {
-      mockPresidio({});
+      mockDetector({});
 
       const detector = new PIIDetector();
       const request = createRequest([]);
@@ -124,7 +124,7 @@ describe("PIIDetector", () => {
     });
 
     test("handles multimodal content", async () => {
-      mockPresidio({
+      mockDetector({
         "Hans Müller": [{ entity_type: "PERSON", start: 0, end: 11, score: 0.9 }],
       });
 
@@ -148,7 +148,7 @@ describe("PIIDetector", () => {
     });
 
     test("skips messages with empty content", async () => {
-      mockPresidio({
+      mockDetector({
         test: [{ entity_type: "PERSON", start: 0, end: 4, score: 0.9 }],
       });
 
@@ -167,8 +167,8 @@ describe("PIIDetector", () => {
   });
 
   describe("detectPII", () => {
-    test("returns entities from Presidio", async () => {
-      mockPresidio({
+    test("returns entities from the detector", async () => {
+      mockDetector({
         "test@example.com": [{ entity_type: "EMAIL_ADDRESS", start: 0, end: 16, score: 0.99 }],
       });
 
@@ -180,7 +180,7 @@ describe("PIIDetector", () => {
     });
 
     test("returns empty array for text without PII", async () => {
-      mockPresidio({});
+      mockDetector({});
 
       const detector = new PIIDetector();
       const entities = await detector.detectPII("Hello world", "en");
@@ -190,8 +190,8 @@ describe("PIIDetector", () => {
   });
 
   describe("healthCheck", () => {
-    test("returns true when Presidio is healthy", async () => {
-      mockPresidio({});
+    test("returns true when the detector is healthy", async () => {
+      mockDetector({});
 
       const detector = new PIIDetector();
       const healthy = await detector.healthCheck();
@@ -199,7 +199,7 @@ describe("PIIDetector", () => {
       expect(healthy).toBe(true);
     });
 
-    test("returns false when Presidio is unavailable", async () => {
+    test("returns false when the detector is unavailable", async () => {
       globalThis.fetch = mock(async () => {
         throw new Error("Connection refused");
       }) as unknown as typeof fetch;
index 13e3c29838f8a46710fe53cff075f6fc3ea4cf75..edf13e4a6705bacc8e2fef259ee2337aea420e3a 100644 (file)
@@ -43,20 +43,19 @@ export interface PIIDetectionResult {
 }
 
 export class PIIDetector {
-  private presidioUrl: string;
+  private detectorUrl: string;
   private scoreThreshold: number;
   private entityTypes: string[];
-  private languageValidation?: { available: string[]; missing: string[] };
 
   constructor() {
     const config = getConfig();
-    this.presidioUrl = config.pii_detection.presidio_url;
+    this.detectorUrl = config.pii_detection.detector_url;
     this.scoreThreshold = config.pii_detection.score_threshold;
     this.entityTypes = config.pii_detection.entities;
   }
 
   async detectPII(text: string, language: SupportedLanguage): Promise<PIIEntity[]> {
-    const analyzeEndpoint = `${this.presidioUrl}/analyze`;
+    const analyzeEndpoint = `${this.detectorUrl}/analyze`;
 
     const request: AnalyzeRequest = {
       text,
@@ -78,7 +77,7 @@ export class PIIDetector {
       if (!response.ok) {
         const errorText = await response.text();
         throw new Error(
-          `Presidio API error: ${response.status} ${response.statusText} - ${errorText}`,
+          `Detector API error: ${response.status} ${response.statusText} - ${errorText}`,
         );
       }
 
@@ -86,7 +85,9 @@ export class PIIDetector {
     } catch (error) {
       if (error instanceof Error) {
         if (error.message.includes("fetch")) {
-          throw new Error(`Failed to connect to Presidio at ${this.presidioUrl}: ${error.message}`);
+          throw new Error(
+            `Failed to connect to the PII detector at ${this.detectorUrl}: ${error.message}`,
+          );
         }
         throw error;
       }
@@ -146,7 +147,7 @@ export class PIIDetector {
 
   async healthCheck(): Promise<boolean> {
     try {
-      const response = await fetch(`${this.presidioUrl}/health`, {
+      const response = await fetch(`${this.detectorUrl}/health`, {
         method: "GET",
         signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
       });
@@ -157,7 +158,7 @@ export class PIIDetector {
   }
 
   /**
-   * Wait for Presidio to be ready (for docker-compose startup order)
+   * Wait for the detector to be ready (for docker-compose startup order)
    */
   async waitForReady(maxRetries = 30, delayMs = 1000): Promise<boolean> {
     for (let i = 1; i <= maxRetries; i++) {
@@ -167,7 +168,7 @@ export class PIIDetector {
       if (i < maxRetries) {
         // Show initial message, then every 5 attempts
         if (i === 1) {
-          process.stdout.write("[STARTUP] Waiting for Presidio");
+          process.stdout.write("[STARTUP] Waiting for the detector");
         } else if (i % 5 === 0) {
           process.stdout.write(".");
         }
@@ -177,66 +178,6 @@ export class PIIDetector {
     process.stdout.write("\n");
     return false;
   }
-
-  /**
-   * Test if a language is supported by trying to analyze with it
-   */
-  async isLanguageSupported(language: string): Promise<boolean> {
-    try {
-      const response = await fetch(`${this.presidioUrl}/analyze`, {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({
-          text: "test",
-          language,
-          entities: ["PERSON"],
-        }),
-        signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
-      });
-
-      // If we get a response (even empty array), the language is supported
-      // If we get an error like "No matching recognizers", it's not supported
-      if (response.ok) {
-        return true;
-      }
-
-      const errorText = await response.text();
-      return !errorText.includes("No matching recognizers");
-    } catch {
-      return false;
-    }
-  }
-
-  /**
-   * Validate multiple languages, return available/missing
-   */
-  async validateLanguages(languages: string[]): Promise<{
-    available: string[];
-    missing: string[];
-  }> {
-    const results = await Promise.all(
-      languages.map(async (lang) => ({
-        lang,
-        supported: await this.isLanguageSupported(lang),
-      })),
-    );
-
-    this.languageValidation = {
-      available: results.filter((r) => r.supported).map((r) => r.lang),
-      missing: results.filter((r) => !r.supported).map((r) => r.lang),
-    };
-
-    return this.languageValidation;
-  }
-
-  /**
-   * Get the cached language validation result
-   */
-  getLanguageValidation(): { available: string[]; missing: string[] } | undefined {
-    return this.languageValidation;
-  }
 }
 
 let detectorInstance: PIIDetector | null = null;
index 0b6cb83321881dbb870b129d1089a3972e48c56d..d053e9d35eb3249a710f5f1cb2c12d7baa55c17d 100644 (file)
@@ -2,7 +2,7 @@ import { describe, expect, mock, test } from "bun:test";
 import { Hono } from "hono";
 import { filterWhitelistedEntities, type PIIEntity } from "../pii/detect";
 
-// Mock the PII detector to avoid needing Presidio running
+// Mock the PII detector to avoid needing the detector running
 const mockDetectPII = mock<(text: string, language: string) => Promise<PIIEntity[]>>(() =>
   Promise.resolve([]),
 );
@@ -10,7 +10,6 @@ mock.module("../pii/detect", () => ({
   getPIIDetector: () => ({
     detectPII: mockDetectPII,
     healthCheck: mock(() => Promise.resolve(true)),
-    getLanguageValidation: mock(() => undefined),
   }),
   filterWhitelistedEntities,
 }));
@@ -20,6 +19,30 @@ mock.module("../services/logger", () => ({
   logRequest: mock(() => {}),
 }));
 
+// Enable every secret type so the ordering test doesn't depend on the ambient config.
+const realConfig = await import("../config");
+const baseConfig = realConfig.getConfig();
+const testConfig = {
+  ...baseConfig,
+  secrets_detection: {
+    ...baseConfig.secrets_detection,
+    enabled: true,
+    entities: [
+      "OPENSSH_PRIVATE_KEY",
+      "PEM_PRIVATE_KEY",
+      "API_KEY_SK",
+      "API_KEY_AWS",
+      "API_KEY_GITHUB",
+      "JWT_TOKEN",
+      "BEARER_TOKEN",
+      "ENV_PASSWORD",
+      "ENV_SECRET",
+      "CONNECTION_STRING",
+    ],
+  },
+};
+mock.module("../config", () => ({ ...realConfig, getConfig: () => testConfig }));
+
 // Import after mocks are set up
 const { apiRoutes } = await import("./api");
 
@@ -222,6 +245,43 @@ describe("POST /api/mask", () => {
     expect(body.entities.some((e) => e.type === "PEM_PRIVATE_KEY")).toBe(true);
   });
 
+  test("masks a connection string as a secret even when a PII email span overlaps it", async () => {
+    // Mock mirrors the real email detector: matches only if the email survived (i.e. secrets ran first).
+    mockDetectPII.mockImplementationOnce((text: string) => {
+      const m = text.match(/[\w.+-]+@[\w.-]+\.\w+/);
+      return Promise.resolve(
+        m && m.index !== undefined
+          ? [
+              {
+                entity_type: "EMAIL_ADDRESS",
+                start: m.index,
+                end: m.index + m[0].length,
+                score: 0.9,
+              },
+            ]
+          : [],
+      );
+    });
+
+    const res = await app.request("/api/mask", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        text: "Connection: postgres://admin:S3cretPass@db.example.com:5432/appdb",
+      }),
+    });
+
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as {
+      masked: string;
+      entities: { type: string }[];
+    };
+    expect(body.masked).toContain("[[CONNECTION_STRING_1]]");
+    expect(body.entities.some((e) => e.type === "CONNECTION_STRING")).toBe(true);
+    expect(body.masked).not.toContain("[[EMAIL_ADDRESS");
+    expect(body.entities.some((e) => e.type === "EMAIL_ADDRESS")).toBe(false);
+  });
+
   test("returns 400 for malformed JSON", async () => {
     const res = await app.request("/api/mask", {
       method: "POST",
@@ -235,7 +295,7 @@ describe("POST /api/mask", () => {
   });
 
   test("returns 503 when PII detection fails", async () => {
-    mockDetectPII.mockRejectedValueOnce(new Error("Presidio connection failed"));
+    mockDetectPII.mockRejectedValueOnce(new Error("Detector connection failed"));
 
     const res = await app.request("/api/mask", {
       method: "POST",
@@ -249,7 +309,7 @@ describe("POST /api/mask", () => {
     };
     expect(body.error.type).toBe("detection_error");
     expect(body.error.message).toBe("PII detection failed");
-    expect(body.error.details[0].message).toBe("Presidio connection failed");
+    expect(body.error.details[0].message).toBe("Detector connection failed");
   });
 
   test("includes languageFallback in response", async () => {
index 4c79863adeea96609d16e44c5e2715dc711101e6..5806bcad6ac15e0c89e4d3269c1193831cb9f46d 100644 (file)
@@ -132,61 +132,7 @@ apiRoutes.post("/mask", async (c) => {
   const secretTypes: string[] = [];
   let scanTimeMs = 0;
 
-  // Detect and mask PII
-  if (detectPII) {
-    try {
-      const piiStartTime = Date.now();
-      const detector = getPIIDetector();
-      const piiEntities = await detector.detectPII(maskedText, language);
-      scanTimeMs = Date.now() - piiStartTime;
-
-      // Apply whitelist filtering
-      const filteredEntities = filterWhitelistedEntities(
-        maskedText,
-        piiEntities,
-        config.masking.whitelist,
-      );
-
-      // Capture counters before masking to track new entities
-      const countersBefore = { ...context.counters };
-      const piiResult = maskPII(maskedText, filteredEntities, context);
-      maskedText = piiResult.masked;
-      allEntities.push(...extractEntities(countersBefore, piiResult.context));
-
-      // Collect unique entity types for logging
-      for (const entity of filteredEntities) {
-        if (!piiEntityTypes.includes(entity.entity_type)) {
-          piiEntityTypes.push(entity.entity_type);
-        }
-      }
-    } catch (error) {
-      // Log the error
-      logRequest(
-        createLogData({
-          provider: "api",
-          model: "mask",
-          startTime,
-          pii: { hasPII: false, entityTypes: [], language, languageFallback, scanTimeMs: 0 },
-          statusCode: 503,
-          errorMessage: error instanceof Error ? error.message : "PII detection failed",
-        }),
-        userAgent,
-      );
-
-      return c.json(
-        {
-          error: {
-            message: "PII detection failed",
-            type: "detection_error",
-            details: [{ message: error instanceof Error ? error.message : "Unknown error" }],
-          },
-        },
-        503,
-      );
-    }
-  }
-
-  // Detect and mask secrets
+  // Secrets before PII (as in the provider routes): otherwise PII masks a connection string's "pass@host" as an email and the CONNECTION_STRING pattern can't match.
   if (detectSecretsFlag && config.secrets_detection.enabled) {
     try {
       // Create a config for detection (always use mask action for API)
@@ -247,6 +193,60 @@ apiRoutes.post("/mask", async (c) => {
     }
   }
 
+  // Detect and mask PII
+  if (detectPII) {
+    try {
+      const piiStartTime = Date.now();
+      const detector = getPIIDetector();
+      const piiEntities = await detector.detectPII(maskedText, language);
+      scanTimeMs = Date.now() - piiStartTime;
+
+      // Apply whitelist filtering
+      const filteredEntities = filterWhitelistedEntities(
+        maskedText,
+        piiEntities,
+        config.masking.whitelist,
+      );
+
+      // Capture counters before masking to track new entities
+      const countersBefore = { ...context.counters };
+      const piiResult = maskPII(maskedText, filteredEntities, context);
+      maskedText = piiResult.masked;
+      allEntities.push(...extractEntities(countersBefore, piiResult.context));
+
+      // Collect unique entity types for logging
+      for (const entity of filteredEntities) {
+        if (!piiEntityTypes.includes(entity.entity_type)) {
+          piiEntityTypes.push(entity.entity_type);
+        }
+      }
+    } catch (error) {
+      // Log the error
+      logRequest(
+        createLogData({
+          provider: "api",
+          model: "mask",
+          startTime,
+          pii: { hasPII: false, entityTypes: [], language, languageFallback, scanTimeMs: 0 },
+          statusCode: 503,
+          errorMessage: error instanceof Error ? error.message : "PII detection failed",
+        }),
+        userAgent,
+      );
+
+      return c.json(
+        {
+          error: {
+            message: "PII detection failed",
+            type: "detection_error",
+            details: [{ message: error instanceof Error ? error.message : "Unknown error" }],
+          },
+        },
+        503,
+      );
+    }
+  }
+
   // Log successful request
   logRequest(
     createLogData({
index bc05247502dee709941304618991791fa7074d3b..c92763ee1a0f6b69b7cca2d8c9ebdc2f6f7b1857 100644 (file)
@@ -22,7 +22,6 @@ mock.module("../pii/detect", () => ({
     analyzeRequest: mockAnalyzeRequest,
     detectPII: mock(() => Promise.resolve([])),
     healthCheck: mock(() => Promise.resolve(true)),
-    getLanguageValidation: mock(() => undefined),
   }),
 }));
 
index 5bd3c2741b16e5f96e19e8e66425328074e7a7e7..97ebac1c130164e0d2768c855f49a353476ab5a3 100644 (file)
@@ -9,7 +9,7 @@ describe("GET /health", () => {
   test("returns health status", async () => {
     const res = await app.request("/health");
 
-    // May be 200 (healthy) or 503 (degraded) depending on Presidio
+    // May be 200 (healthy) or 503 (degraded) depending on the detector
     expect([200, 503]).toContain(res.status);
 
     const body = (await res.json()) as Record<string, unknown>;
index e8051ab3a6836d85894ba915684093294c35b55e..a56ab795111818f95747c763c9c376954d188d06 100644 (file)
@@ -1,7 +1,7 @@
 import { Hono } from "hono";
 import { getConfig } from "../config";
 import { checkLocalHealth } from "../providers/local";
-import { healthCheck as checkPresidio } from "../services/pii";
+import { healthCheck as checkDetector } from "../services/pii";
 
 export const healthRoutes = new Hono();
 
@@ -9,18 +9,18 @@ healthRoutes.get("/health", async (c) => {
   const config = getConfig();
   const piiEnabled = config.pii_detection.enabled;
 
-  const [presidioHealth, localHealth] = await Promise.all([
-    piiEnabled ? checkPresidio() : Promise.resolve(true),
+  const [detectorHealth, localHealth] = await Promise.all([
+    piiEnabled ? checkDetector() : Promise.resolve(true),
     config.mode === "route" && config.local
       ? checkLocalHealth(config.local)
       : Promise.resolve(true),
   ]);
 
-  const isHealthy = piiEnabled ? presidioHealth : true;
+  const isHealthy = piiEnabled ? detectorHealth : true;
 
   const services: Record<string, string> = {};
   if (piiEnabled) {
-    services.presidio = presidioHealth ? "up" : "down";
+    services.detector = detectorHealth ? "up" : "down";
   }
 
   if (config.mode === "route" && config.local) {
index 6fcf0427d2dbe9945503ebfeff0b0f598069eb59..fca7a13af5a5f7a3a703b33db80ec93e2243c923 100644 (file)
@@ -1,7 +1,6 @@
 import { Hono } from "hono";
 import pkg from "../../package.json";
 import { getConfig } from "../config";
-import { getPIIDetector } from "../pii/detect";
 import { getAnthropicInfo } from "../providers/anthropic/client";
 import { getLocalInfo } from "../providers/local";
 import { getOpenAIInfo } from "../providers/openai/client";
@@ -10,8 +9,6 @@ export const infoRoutes = new Hono();
 
 infoRoutes.get("/info", (c) => {
   const config = getConfig();
-  const detector = getPIIDetector();
-  const languageValidation = detector.getLanguageValidation();
 
   const providers = {
     openai: {
@@ -32,13 +29,7 @@ infoRoutes.get("/info", (c) => {
     mode: config.mode,
     providers,
     pii_detection: {
-      languages: languageValidation
-        ? {
-            configured: config.pii_detection.languages,
-            available: languageValidation.available,
-            missing: languageValidation.missing,
-          }
-        : config.pii_detection.languages,
+      languages: config.pii_detection.languages,
       fallback_language: config.pii_detection.fallback_language,
       score_threshold: config.pii_detection.score_threshold,
       entities: config.pii_detection.entities,
index dd485411fd383ea636ba1eff30f0ccbdc93063e8..0baf7c5fe3d582d73f3f739a5de6a955d990f1a5 100644 (file)
@@ -11,8 +11,8 @@ export interface LanguageDetectionResult {
   confidence?: number;
 }
 
-// Special case mapping: Norwegian detected as "no" but Presidio expects "nb"
-const ISO_TO_PRESIDIO_OVERRIDES: Record<string, SupportedLanguage> = {
+// Map detected ISO codes onto the supported-language list where they differ.
+const ISO_TO_SUPPORTED_OVERRIDES: Record<string, SupportedLanguage> = {
   no: "nb", // Norwegian (generic) → Norwegian Bokmål
 };
 
@@ -33,12 +33,12 @@ export class LanguageDetector {
     const confidence = scores[detectedIso] ?? 0;
 
     // Use override if exists, otherwise use the detected code as-is (most are 1:1)
-    const presidioLang = (ISO_TO_PRESIDIO_OVERRIDES[detectedIso] ||
+    const mappedLang = (ISO_TO_SUPPORTED_OVERRIDES[detectedIso] ||
       detectedIso) as SupportedLanguage;
 
-    if (presidioLang && this.configuredLanguages.includes(presidioLang)) {
+    if (mappedLang && this.configuredLanguages.includes(mappedLang)) {
       return {
-        language: presidioLang,
+        language: mappedLang,
         usedFallback: false,
         detectedLanguage: detectedIso,
         confidence,
index b557685b33a086c129cdfbba5a2f8f099a4a61d5..46e9382a322b65ecdae3f4be0ae96dc9c3f8fc8c 100644 (file)
@@ -62,7 +62,7 @@ export type { PIIDetectionResult, PIIEntity } from "../pii/detect";
 export { createMaskingContext } from "../pii/mask";
 
 /**
- * Check if Presidio is healthy
+ * Check if the detector is healthy
  */
 export async function healthCheck(): Promise<boolean> {
   const detector = getPIIDetector();
git clone https://git.99rst.org/PROJECT