Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file.
## [unreleased]

### Analyzer
#### Added
- Korean Corporate Registration Number (KR_CRN) recognizer, complementing the existing Business Registration Number (KR_BRN) recognizer with the court-registry corporate identifier — analogous to AU_ACN and SG_UEN for their jurisdictions. Includes check digit validation for CRNs issued before January 31, 2025 (the check digit was abolished by Supreme Court Rule No. 3173).

#### Fixed
- `PhoneRecognizer.DEFAULT_SUPPORTED_REGIONS` used `"UK"`, which is not a valid `phonenumbers` (libphonenumber) region code — region codes are ISO 3166-1 alpha-2, where the United Kingdom is `"GB"`. The `"UK"` entry was a no-op, so UK numbers in national/local format (e.g. `020 7946 0958`) were never detected by default; only international-format `+44 …` numbers matched, because they carry the country code and match under any region. Replaced `"UK"` with `"GB"`.
- Language model recognizers (`BasicLangExtractRecognizer`, `AzureOpenAILangExtractRecognizer`) configured in a recognizer registry YAML now honour `config_path` (and other recognizer-specific kwargs). Previously these entries were validated by the strict `PredefinedRecognizerConfig` schema, which has no `config_path` field and does not allow extra keys, so `config_path` was silently dropped and the recognizer fell back to its bundled default model configuration. Added a `LangExtractRecognizerConfig` model (`extra="allow"`) and registered both recognizer class names in `CONFIG_MODEL_MAP`.
Expand Down
1 change: 1 addition & 0 deletions docs/supported_entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ For more information, refer to the [adding new recognizers documentation](analyz
| KR_FRN | The Korean Foreigner Registration Number (FRN) is a 13-digit number. | Pattern match, context and custom logic. |
| KR_PASSPORT| The Korean Passport Number | Pattern match, context. |
| KR_BRN | The Korean Business Registration Number (BRN) is a 10-digit number assigned to business entities for taxation purposes. | Pattern match, context and custom logic. |
| KR_CRN | The Korean Corporate Registration Number (CRN) is a 13-digit number assigned by the court registry office to legal entities upon incorporation. | Pattern match, context and custom logic. |
| KR_RRN | The Korean Resident Registration Number (RRN) is a 13-digit number issued to all Korean residents. | Pattern match, context and custom logic. |


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,15 @@ recognizers:
country_code: pl

- name: KrBrnRecognizer
supported_languages:
supported_languages:
- ko
- kr
type: predefined
enabled: false
country_code: kr

- name: KrCrnRecognizer
supported_languages:
- ko
- kr
type: predefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

# Korea recognizers
from .country_specific.korea.kr_brn_recognizer import KrBrnRecognizer
from .country_specific.korea.kr_crn_recognizer import KrCrnRecognizer
from .country_specific.korea.kr_driver_license_recognizer import (
KrDriverLicenseRecognizer,
)
Expand Down Expand Up @@ -242,6 +243,7 @@
"UkVehicleRegistrationRecognizer",
"AzureHealthDeidRecognizer",
"KrBrnRecognizer",
"KrCrnRecognizer",
"KrRrnRecognizer",
"KrDriverLicenseRecognizer",
"KrFrnRecognizer",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
"""Korea-specific recognizers."""

from .kr_brn_recognizer import KrBrnRecognizer
from .kr_crn_recognizer import KrCrnRecognizer
from .kr_driver_license_recognizer import KrDriverLicenseRecognizer
from .kr_frn_recognizer import KrFrnRecognizer
from .kr_passport_recognizer import KrPassportRecognizer
from .kr_rrn_recognizer import KrRrnRecognizer

__all__ = [
"KrDriverLicenseRecognizer",
"KrPassportRecognizer",
"KrFrnRecognizer",
"KrBrnRecognizer",
"KrCrnRecognizer",
"KrDriverLicenseRecognizer",
"KrFrnRecognizer",
"KrPassportRecognizer",
"KrRrnRecognizer",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
from typing import List, Optional, Tuple

from presidio_analyzer import EntityRecognizer, Pattern, PatternRecognizer


class KrCrnRecognizer(PatternRecognizer):
"""
Recognize Korean Corporate Registration Number (CRN).

The Korean Corporate Registration Number (CRN, 법인등록번호) is a
13-digit number assigned by the court registry office when a legal
entity is incorporated in South Korea. It identifies the legal entity
itself, unlike the Business Registration Number (KR_BRN) which is a
tax identifier.

The format is AAAABB-CCCCCCD where:
- AAAA is the registry office code
- BB is the corporate type code (11-15 commercial companies,
21-22 civil-law corporations, 31-53 special-law corporations,
71 other, 81-86 foreign corporations)
- CCCCCC is a serial number
- D is a check digit calculated over the preceding 12 digits

For CRNs issued on or after January 31, 2025 (Supreme Court Rule
No. 3173), the check digit was abolished and the last 7 digits are
all serial digits, so the check digit validation only applies to
CRNs issued before that date.

Reference: Rules on assignment of registration numbers for real estate
registration of corporations (법인 및 재외국민의 부동산등기용등록번호
부여에 관한 규칙), https://www.law.go.kr/LSW/lsInfoP.do?lsId=005861

:param patterns: List of patterns to be used by this recognizer
:param context: List of context words to increase confidence in detection
:param supported_language: Language this recognizer supports
:param supported_entity: The entity this recognizer can detect
:param replacement_pairs: List of tuples with potential replacement values
for different strings to be used during pattern matching.
This can allow a greater variety in input, for example by removing dashes.
:param name: The name of this recognizer
"""

COUNTRY_CODE = "kr"

PATTERNS = [
Pattern(
"CRN (Medium)",
r"(?<!\d)\d{4}(1[1-5]|2[12]|3[1-9]|4\d|5[0-3]|71|8[1-6])(-?)\d{7}(?!\d)",
0.5,
)
]

CONTEXT = [
"법인등록번호",
"법인번호",
"법인 등록 번호",
"등기부",
"법인등기",
"Korean CRN",
"Corporate Registration Number",
"corporation registration number",
"CRN",
"crn",
"crn#",
]

def __init__(
self,
patterns: Optional[List[Pattern]] = None,
context: Optional[List[str]] = None,
supported_language: str = "ko",
supported_entity: str = "KR_CRN",
replacement_pairs: Optional[List[Tuple[str, str]]] = None,
name: Optional[str] = None,
):
self.replacement_pairs = replacement_pairs if replacement_pairs else [("-", "")]

patterns = patterns if patterns else self.PATTERNS
context = context if context else self.CONTEXT
super().__init__(
supported_entity=supported_entity,
patterns=patterns,
context=context,
supported_language=supported_language,
name=name,
)

def validate_result(self, pattern_text: str) -> Optional[bool]:
"""
Validate the pattern logic e.g., by running checksum on a detected pattern.

This validation only applies to CRNs issued before January 31, 2025.
CRNs issued on or after that date carry no check digit (the last
7 digits are all serial digits), so a failed checksum does not
prove the number invalid. Therefore, this method returns None,
not False, when the checksum does not match.

:param pattern_text: The text detected by the regex engine
:return: True if the checksum matches (definitely a pre-2025 CRN),
None otherwise
"""
sanitized_value = EntityRecognizer.sanitize_value(
pattern_text, self.replacement_pairs
)

if len(sanitized_value) != 13 or not sanitized_value.isdigit():
return None

if self._validate_checksum(sanitized_value):
return True

return None

@staticmethod
def _validate_checksum(crn: str) -> bool:
"""
Validate the check digit of a Korean Corporate Registration Number.

The check digit is calculated by multiplying the first 12 digits
alternately by 1 and 2, summing the products, and subtracting the
remainder of the sum divided by 10 from 10 (modulo 10):
check digit = (10 - (sum mod 10)) mod 10

:param crn: The 13-digit CRN string to validate
:return: True if the check digit matches, False otherwise
"""
digits = [int(d) for d in crn]

total = sum(
digit * (1 if i % 2 == 0 else 2) for i, digit in enumerate(digits[:12])
)
check_digit = (10 - total % 10) % 10

return check_digit == digits[12]
190 changes: 190 additions & 0 deletions presidio-analyzer/tests/test_kr_crn_recognizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import copy
import tempfile
from pathlib import Path

import presidio_analyzer
import pytest
import yaml
from presidio_analyzer.predefined_recognizers.country_specific.korea.kr_crn_recognizer import (
KrCrnRecognizer,
)
from presidio_analyzer.recognizer_registry import RecognizerRegistryProvider

from tests import assert_result_within_score_range


@pytest.fixture(scope="module")
def recognizer():
return KrCrnRecognizer()


@pytest.fixture(scope="module")
def entities():
return ["KR_CRN"]


@pytest.mark.parametrize(
"text, expected_len, expected_positions, expected_score_ranges",
[
# fmt: off
# Valid CRNs (check digit matches -> validated, max score) ---
# Stock company (corporate type code 11)
(
"110111-1234569",
1,
((0, 14),),
((1.0, 1.0),),
),
# Without hyphen
(
"1101111234569",
1,
((0, 13),),
((1.0, 1.0),),
),
# Incorporated foundation (corporate type code 22)
(
"134322-0000114",
1,
((0, 14),),
((1.0, 1.0),),
),
# Foreign stock company (corporate type code 81)
(
"110181-0001235",
1,
((0, 14),),
((1.0, 1.0),),
),
# Inside a Korean sentence
(
"법인등록번호 110111-1234569",
1,
((7, 21),),
((1.0, 1.0),),
),
# Format matches but check digit does not (score stays at pattern
# score: CRNs issued on or after 2025-01-31 carry no check digit) ---
(
"110111-1234560",
1,
((0, 14),),
((0.5, 0.5),),
),
# Invalid corporate type code (positions 5-6) -> no match ---
# 16 is not an assigned corporate type code
(
"110116-1234567",
0,
(),
(),
),
# 00 is not an assigned corporate type code
(
"110100-1234567",
0,
(),
(),
),
# 61 is not an assigned corporate type code
(
"110161-1234567",
0,
(),
(),
),
# Invalid format -> no match ---
# Too short
(
"110111-123456",
0,
(),
(),
),
# Too long
(
"110111-12345678",
0,
(),
(),
),
# Contains letters
(
"110111-123456A",
0,
(),
(),
),
# fmt: on
],
)
def test_when_all_crns_then_succeed(
text,
expected_len,
expected_positions,
expected_score_ranges,
recognizer,
entities,
max_score,
):
results = recognizer.analyze(text, entities)
assert len(results) == expected_len
for res, (st_pos, fn_pos), (st_score, fn_score) in zip(
results, expected_positions, expected_score_ranges
):
if fn_score == "max":
fn_score = max_score
assert_result_within_score_range(
res, entities[0], st_pos, fn_pos, st_score, fn_score
)


def test_checksum_validation():
"""Check digit = (10 - (alternating 1,2 weighted sum mod 10)) mod 10."""
assert KrCrnRecognizer._validate_checksum("1101111234569")
assert not KrCrnRecognizer._validate_checksum("1101111234560")


def test_failed_checksum_returns_none_not_false(recognizer):
"""A failed checksum must not invalidate the result.

CRNs issued on or after January 31, 2025 (Supreme Court Rule No. 3173)
have a 7-digit serial number and no check digit, so a mismatch does not
prove the number invalid.
"""
assert recognizer.validate_result("110111-1234569") is True
assert recognizer.validate_result("110111-1234560") is None


def test_default_supported_language_is_ko():
"""Default language must be ``ko``, like the other Korean recognizers."""
assert KrCrnRecognizer().supported_language == "ko"


def test_accepts_name_kwarg():
"""Constructor must accept the ``name`` kwarg the YAML loader passes."""
recognizer = KrCrnRecognizer(name="CustomKrCrn")
assert recognizer.name == "CustomKrCrn"


@pytest.mark.parametrize("language", ["ko", "kr"])
def test_loads_from_default_recognizers_yaml(language):
"""Recognizer is registered in the default YAML and loads once enabled."""
conf = Path(presidio_analyzer.__file__).parent / "conf" / "default_recognizers.yaml"
recognizers = yaml.safe_load(conf.read_text(encoding="utf-8"))["recognizers"]
entries = [r for r in recognizers if r.get("name") == "KrCrnRecognizer"]
assert len(entries) == 1, "KrCrnRecognizer missing from YAML"
entry = entries[0]
assert entry["country_code"] == "kr"
assert language in entry["supported_languages"]

entry = copy.deepcopy(entry)
entry["enabled"] = True
tmp = Path(tempfile.mkdtemp()) / "conf.yaml"
tmp.write_text(
yaml.safe_dump({"supported_languages": [language], "recognizers": [entry]})
)
provider = RecognizerRegistryProvider(conf_file=str(tmp))
registry = provider.create_recognizer_registry()
entities = {e for rec in registry.recognizers for e in rec.supported_entities}
assert "KR_CRN" in entities
Loading