Skip to main content

Custom Dictionary Recognition

Overview​

Presidio and regex catch the generic identifiers (PERSON, BSN, IBAN, EMAIL). They do not — and cannot — catch the identifiers that only a specific municipality knows are sensitive: an internal project codename ("Operatie Zilverreiger"), a local street or buurt name a model misses, a case-file code format unique to that organisation, or the name of a whistle-blower.

Municipal anonymisation tooling treats an organisation-managed term list as core, not a nice-to-have:

  • Octobox Anonimiseren (Gemeente Zaanstad, algoritmeregister gm0479/24449830) is described as "algoritmes, waardelijsten voor automatische termherkenning en NLP" — value-lists sit next to the NLP model, not inside it.
  • Anonimiseringssoftware Gemeente Noordwijk (algoritmeregister gm0575/91386595) is registered as municipal PII detection tuned by value-lists rather than a fixed entity model.

Custom dictionary recognition closes this recall gap: an organisation maintains its own list of terms, Filinq matches those terms against every document's extracted text, and a match becomes a CUSTOM_DICTIONARY occurrence in OpenRegister's shared entity catalogue — appearing in detection results, the review workbench, the grondslagen summary and redaction exactly like a Presidio or regex hit.

Filinq adds a recognizer, not an engine: OpenRegister still owns extraction, the catalogue, review and redaction. Nothing about EntityRecognitionHandler, its type constants, its regex set or its redaction placeholder format changes.

How It Works​

  1. An organisation manager creates a custom dictionary — a label, colour, match mode, and an active flag — from the "Custom dictionaries" admin page.
  2. Terms are added one at a time, or bulk-loaded via CSV or newline-list import (server-side parsing only; trims, skips blanks, de-duplicates case-insensitively, and reports {added, skipped, total}).
  3. When a document is extracted and detected (AnonymizationService::extractAndDetectEntities), Filinq runs a best-effort pass after OpenRegister's own extraction: for every active dictionary the caller's organisation can access, it matches that dictionary's terms against the file's text chunks (CustomDictionaryMatchService) and writes every occurrence into OpenRegister's catalogue via EntityRelationMapper — entity type CUSTOM_DICTIONARY, category contextual_data, detectionMethod = custom_dictionary, confidence 1.0.
  4. The pass is idempotent: before writing, it clears the file's prior custom_dictionary relations, so re-running detection never appends duplicates.
  5. The pass is best-effort: a failure is logged and surfaced as a customDictionaryWarning string on the detection response, but OpenRegister's own detected entities are always still returned.
  6. CUSTOM_DICTIONARY participates in the same enabled-entity-type whitelist as every other type (GrondslagProposalService::getEntityTypeWhitelist) — when the operator disables it, the matcher is skipped entirely (dictionaries stay manageable but stop auto-detecting).

Match Modes​

ModeRule
exactCase-sensitive, byte-for-byte match.
caseInsensitive (default)Case-folded on both sides.
wordBoundaryCase-insensitive and delimited by a non-word boundary, so "Berg" does not match inside "Bergen".

Match mode is set per dictionary — every term in a dictionary shares that dictionary's mode. All modes return every occurrence (not just the first), and overlapping terms are resolved longest-term-first, so a shorter term can never pre-empt a longer one at the same position (mirrors OpenRegister's own redaction longest-needle rule).

A fuzzy flag exists on the schema and is accepted by the API, but is explicitly deferred — no approximate/near-miss matching in this version.

Organisation Scoping​

Dictionaries and terms are organisation-scoped, fail-closed: a caller only ever sees or edits dictionaries whose organisation is among their own accessible organisations. This is enforced server-side in CustomDictionaryService, not merely hidden in the UI — a cross-organisation GET/PUT/DELETE returns HTTP 403, and a cross-organisation dictionary is silently absent from list responses (never "everyone may see it" as a fallback).

API​

All routes live under api/custom-dictionaries (Filinq's own controller, not a bare OpenRegister object proxy — see CustomDictionaryController's ADR-022 justification):

MethodRoutePurpose
GETapi/custom-dictionariesList dictionaries visible to the caller.
POSTapi/custom-dictionariesCreate a dictionary.
GETapi/custom-dictionaries/{id}Show one dictionary (with live termCount).
PUTapi/custom-dictionaries/{id}Update a dictionary.
DELETEapi/custom-dictionaries/{id}Delete a dictionary (cascade-deletes its terms).
GETapi/custom-dictionaries/{id}/termsList a dictionary's terms.
POSTapi/custom-dictionaries/{id}/termsAdd one term.
DELETEapi/custom-dictionaries/{id}/terms/{termId}Remove one term.
POSTapi/custom-dictionaries/{id}/importCSV/newline import (file multipart field or content + format params).

When OpenRegister is not installed, every route returns HTTP 503 with an explanatory body rather than crashing.

Admin UI​

The "Custom dictionaries" page is registered in the Manifest-V2 shell (src/manifest.json + src/registry.js, never src/router/index.js):

  • Index (CustomDictionaryIndex.vue): a CnIndexPage listing every accessible dictionary with its label, live term count, match mode and active state. "Add" opens CustomDictionaryFormDialog.vue and navigates to the new dictionary's detail page.
  • Detail (CustomDictionaryDetail.vue): dictionary metadata (editable via the same form dialog), a term table with add/remove, and an Import… button opening CustomDictionaryImportDialog.vue for CSV/newline bulk loading.

Playwright MCP screenshots of the live pages were not captured for this revision of the document — this apply pass was implemented and verified (PHPUnit + npm run build) in an isolated worktree without a seeded, deployed Nextcloud instance to screenshot against. Capturing screenshots against a live instance with the seed dictionary loaded is a follow-up.

Seed Data​

One demo dictionary ships for the admin page to render non-empty:

{
"label": "Projectnamen",
"description": "Interne projectcodenamen die niet door Presidio worden herkend",
"colour": "#8E44AD",
"matchMode": "caseInsensitive",
"active": true
}

...with two demo terms: "Operatie Zilverreiger" and "Dossier Karekiet". No real personal data by construction.

Deferred / Follow-ups​

  • Fuzzy matching (Levenshtein / phonetic, for near-misses and OCR noise) — the flag is declared and accepted now; matching is a future change.
  • Localized CUSTOM_DICTIONARY placeholder label — redaction currently shows [CUSTOM_DICTIONARY: n] unless OpenRegister's localizeEntityType() gains a translation for the type; the matching dictionary/term label is carried on the relation's context field for the review UI in the meantime.
  • Cross-organisation ("central + local") dictionaries — v1 is strictly single-organisation ownership.
  • openspec/changes/custom-dictionary-recognition/ — this change.
  • openspec/specs/anonymization/spec.md — the detection → review → anonymise pipeline this recognizer plugs into (consumed, not modified).
  • anonymization-review-workbench — renders CUSTOM_DICTIONARY rows via the shared catalogue; no change to that surface.
  • enable-kenteken-entity-type — orthogonal fixed-type precedent for extending the curated entity-type list.