From fccab78ada6be8487bde8c95aa5e95ce01622b0f Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Tue, 21 Jul 2026 17:30:10 +0900 Subject: [PATCH] feat(analyzer): add Korean Corporate Registration Number (KR_CRN) recognizer Adds KrCrnRecognizer for the 13-digit corporate identifier assigned by the Korean court registry office, complementing the tax-oriented KR_BRN recognizer. The pattern constrains the corporate type code (digits 5-6) to the codes assigned in attached Table 3 of the Supreme Court rule, and validates the check digit for CRNs issued before 2025-01-31, when Supreme Court Rule No. 3173 abolished it. Closes #2177 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + docs/supported_entities.md | 1 + .../conf/default_recognizers.yaml | 10 +- .../predefined_recognizers/__init__.py | 2 + .../country_specific/korea/__init__.py | 6 +- .../korea/kr_crn_recognizer.py | 134 ++++++++++++ .../tests/test_kr_crn_recognizer.py | 190 ++++++++++++++++++ 7 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_crn_recognizer.py create mode 100644 presidio-analyzer/tests/test_kr_crn_recognizer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3c11d97..98cbb53b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/docs/supported_entities.md b/docs/supported_entities.md index e04f770f0..afea79fac 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -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. | diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index a82c62dda..6f680625f 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -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 diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index 3715da7c3..b497041ac 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -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, ) @@ -242,6 +243,7 @@ "UkVehicleRegistrationRecognizer", "AzureHealthDeidRecognizer", "KrBrnRecognizer", + "KrCrnRecognizer", "KrRrnRecognizer", "KrDriverLicenseRecognizer", "KrFrnRecognizer", diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/__init__.py index 9b1e84334..d7fec6e75 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/__init__.py @@ -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", ] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_crn_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_crn_recognizer.py new file mode 100644 index 000000000..0452b45e6 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/korea/kr_crn_recognizer.py @@ -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"(? 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] diff --git a/presidio-analyzer/tests/test_kr_crn_recognizer.py b/presidio-analyzer/tests/test_kr_crn_recognizer.py new file mode 100644 index 000000000..bb97a853e --- /dev/null +++ b/presidio-analyzer/tests/test_kr_crn_recognizer.py @@ -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