diff --git a/dingo/model/rule/scibase/rule_patent.py b/dingo/model/rule/scibase/rule_patent.py index 16a42c30..77d55afd 100644 --- a/dingo/model/rule/scibase/rule_patent.py +++ b/dingo/model/rule/scibase/rule_patent.py @@ -9,6 +9,7 @@ from dingo.model.rule.base import BaseRule MAX_CLAIMS_LENGTH = 1_000_000 +IPC_SUBCLASS_RE = re.compile(r"^[A-H](?:0[1-9]|[1-9][0-9])[A-Z]$") IPC_CODE_RE = re.compile( r"^(?P
[A-H])" r"(?P0[1-9]|[1-9][0-9])" @@ -16,6 +17,34 @@ r"(?P[1-9][0-9]{0,2})/" r"(?P[0-9]{2,6})$" ) +ASCII_ALNUM_RE = re.compile(r"^[A-Za-z0-9]+$") +JP_ERA_CITATION_RE = re.compile( + r"^JP(?P特開|特公|実開|実公)" + r"(?P[平昭])" + r"(?P\d+)-" + r"(?P\d+)" + r"(?P[A-Z]\d?)?$" +) +JP_GREGORIAN_CITATION_RE = re.compile( + r"^JP特開" + r"(?P\d{4})-" + r"(?P\d+)" + r"(?:\([^)]*\))?" + r"(?P[A-Z]\d?)?$" +) +JP_REGISTRATION_CITATION_RE = re.compile( + r"^JP(?:特許第|実用新案登録第|第)" + r"(?P\d+)号" + r"(?:\([^)]*\))?" + r"(?P[A-Z]\d?)$" +) +JP_ERA_CODES = {"平": "H", "昭": "S"} +JP_PUBLICATION_DEFAULT_KIND_CODES = { + "特開": "A", + "特公": "B2", + "実開": "U", + "実公": "Y2", +} ValidationResult = tuple[bool, List[str], List[str]] @@ -56,12 +85,19 @@ def check_ipc_unified(ipc_unified: Any) -> ValidationResult: reasons.append(f"item[{index}] must be a string") continue + # IPC entries whose main group and subgroup are both zero are + # normalized to the four-character subclass code (for example, + # ``F03G 0/00`` becomes ``F03G``). + if IPC_SUBCLASS_RE.fullmatch(ipc_code): + continue + match = IPC_CODE_RE.fullmatch(ipc_code) if match is None: if "invalid_format" not in error_labels: error_labels.append("invalid_format") reasons.append( f"item[{index}] value {ipc_code!r} must match IPC format " + "'<01-99>' or " "'<01-99> <1-999>/<2-6 digits>'" ) continue @@ -78,9 +114,75 @@ def check_ipc_unified(ipc_unified: Any) -> ValidationResult: return bool(error_labels), error_labels, reasons +def _expected_jp_document_number(document_number: str) -> str | None: + """Return the expected normalized value for supported JP citation forms.""" + match = JP_ERA_CITATION_RE.fullmatch(document_number) + if match is not None: + kind_code = match.group("kind_code") or JP_PUBLICATION_DEFAULT_KIND_CODES[ + match.group("publication") + ] + return ( + "JP" + f"{JP_ERA_CODES[match.group('era')]}" + f"{match.group('era_year').zfill(2)}" + f"{match.group('serial').zfill(6)}" + f"{kind_code}" + ) + + match = JP_GREGORIAN_CITATION_RE.fullmatch(document_number) + if match is not None: + kind_code = match.group("kind_code") or "A" + return ( + "JP" + f"{match.group('year')}" + f"{match.group('serial').zfill(6)}" + f"{kind_code}" + ) + + match = JP_REGISTRATION_CITATION_RE.fullmatch(document_number) + if match is not None: + return ( + "JP" + f"{match.group('registration_number')}" + f"{match.group('kind_code')}" + ) + + return None + + +def check_document_number_unifed( + document_number_unifed: Any, + document_number: Any = None, +) -> ValidationResult: + error_labels: List[str] = [] + reasons: List[str] = [] + + if not isinstance(document_number_unifed, str): + return True, ["wrong_type"], ["value must be a string"] + + if ASCII_ALNUM_RE.fullmatch(document_number_unifed) is None: + error_labels.append("invalid_format") + reasons.append("value must contain only ASCII letters and digits") + + if isinstance(document_number, str): + expected = _expected_jp_document_number(document_number) + if expected is not None and document_number_unifed != expected: + error_labels.append("incorrect_conversion") + reasons.append( + f"conversion from document_number {document_number!r} is incorrect: " + f"expected {expected!r}, got {document_number_unifed!r}" + ) + + return bool(error_labels), error_labels, reasons + + FIELD_VALIDATORS = { "claims": lambda record: check_claims(record.get("claims")), "ipc_unified": lambda record: check_ipc_unified(record.get("ipc_unified")), + "document_number_unifed": lambda record: check_document_number_unifed( + record.get("document_number_unifed"), + record.get("document_number"), + ), } diff --git a/test/scripts/model/rule/test_rule_patent.py b/test/scripts/model/rule/test_rule_patent.py index 62552fab..53d3f833 100644 --- a/test/scripts/model/rule/test_rule_patent.py +++ b/test/scripts/model/rule/test_rule_patent.py @@ -1,7 +1,7 @@ import pytest from dingo.io.input import Data -from dingo.model.rule.scibase.rule_patent import MAX_CLAIMS_LENGTH, RulePatentFieldValidation, check_ipc_unified +from dingo.model.rule.scibase.rule_patent import MAX_CLAIMS_LENGTH, RulePatentFieldValidation, check_document_number_unifed, check_ipc_unified def _claims_with_string_length(length: int) -> list[dict]: @@ -14,7 +14,14 @@ def test_claims_at_maximum_length_is_valid(self): claims = _claims_with_string_length(MAX_CLAIMS_LENGTH) assert len(str(claims)) == MAX_CLAIMS_LENGTH - result = RulePatentFieldValidation().eval(Data(claims=claims, ipc_unified=[])) + result = RulePatentFieldValidation().eval( + Data( + claims=claims, + ipc_unified=[], + document_number="JP2013022164A", + document_number_unifed="JP2013022164A", + ) + ) assert result.status is False assert result.label == ["QUALITY_GOOD"] @@ -23,7 +30,14 @@ def test_claims_over_maximum_length_is_invalid(self): claims = _claims_with_string_length(MAX_CLAIMS_LENGTH + 1) assert len(str(claims)) == MAX_CLAIMS_LENGTH + 1 - result = RulePatentFieldValidation().eval(Data(claims=claims, ipc_unified=[])) + result = RulePatentFieldValidation().eval( + Data( + claims=claims, + ipc_unified=[], + document_number="JP2013022164A", + document_number_unifed="JP2013022164A", + ) + ) assert result.status is True assert result.label == ["claims.too_long"] @@ -32,7 +46,13 @@ def test_claims_over_maximum_length_is_invalid(self): ] def test_missing_claims_is_invalid(self): - result = RulePatentFieldValidation().eval(Data(ipc_unified=[])) + result = RulePatentFieldValidation().eval( + Data( + ipc_unified=[], + document_number="JP2013022164A", + document_number_unifed="JP2013022164A", + ) + ) assert result.status is True assert result.label == ["claims.missing_field"] @@ -41,6 +61,7 @@ def test_missing_claims_is_invalid(self): "ipc_unified", [ [], + ["F03G"], ["A01A 1/00"], ["B60T 8/48", "H99Z 999/999999"], '["B60T 8/48", "B60T 8/58"]', @@ -65,6 +86,7 @@ def test_valid_ipc_unified(self, ipc_unified): "A01A 1/1234567", # Subgroup must contain at most six digits. "A01A 1-00", # Groups must be separated by a slash. "A01A 1/00 A", # Extra suffixes are not allowed. + "F03G 0/00", # Zero groups must be reduced to "F03G". ], ) def test_invalid_ipc_format(self, ipc_code): @@ -83,8 +105,10 @@ def test_reports_every_invalid_item_with_its_value(self): assert labels == ["invalid_format"] assert reasons == [ "item[1] value 'C00C 5/27' must match IPC format " + "'<01-99>' or " "'<01-99> <1-999>/<2-6 digits>'", "item[3] value 'C00C 15/08' must match IPC format " + "'<01-99>' or " "'<01-99> <1-999>/<2-6 digits>'", ] @@ -113,9 +137,117 @@ def test_invalid_ipc_unified_value(self, ipc_unified, expected_label): def test_rule_reports_invalid_ipc_unified(self): result = RulePatentFieldValidation().eval( - Data(claims=[], ipc_unified=["A01A 01/00"]) + Data( + claims=[], + ipc_unified=["A01A 01/00"], + document_number="JP2013022164A", + document_number_unifed="JP2013022164A", + ) ) assert result.status is True assert result.label == ["ipc_unified.invalid_format"] assert result.reason[0].startswith("ipc_unified: item[0]") + + @pytest.mark.parametrize( + ("document_number", "document_number_unifed"), + [ + ("JP特開平11-73838A", "JPH11073838A"), + ("JP特開平5-263266A", "JPH05263266A"), + ("JP特開昭63-123456A", "JPS63123456A"), + ("JP特開昭63-123456", "JPS63123456A"), + ("JP特開2003-75095(P2003-75095A)A", "JP2003075095A"), + ("JP特公平7-46852B2", "JPH07046852B2"), + ("JP特公昭48-1234B2", "JPS48001234B2"), + ("JP特許第3405959号(P3405959)B2", "JP3405959B2"), + ("JP実用新案登録第3082161号(U3082161)U", "JP3082161U"), + ("JP実開平6-10396U", "JPH06010396U"), + ("JP実公平7-15070Y2", "JPH07015070Y2"), + ("JP第2891579号B2", "JP2891579B2"), + ], + ) + def test_valid_document_number_conversion( + self, + document_number, + document_number_unifed, + ): + assert check_document_number_unifed( + document_number_unifed, + document_number, + ) == (False, [], []) + + @pytest.mark.parametrize( + "document_number_unifed", + [ + "", + "JP特開平11-73838A", + "JP2013-022164A", + "JP2013022164 A", + ], + ) + def test_document_number_unifed_must_be_ascii_alphanumeric( + self, + document_number_unifed, + ): + invalid, labels, reasons = check_document_number_unifed( + document_number_unifed + ) + + assert invalid is True + assert labels == ["invalid_format"] + assert reasons == ["value must contain only ASCII letters and digits"] + + @pytest.mark.parametrize("document_number_unifed", [None, 123, []]) + def test_document_number_unifed_must_be_string( + self, + document_number_unifed, + ): + assert check_document_number_unifed(document_number_unifed) == ( + True, + ["wrong_type"], + ["value must be a string"], + ) + + def test_reports_incorrect_document_number_conversion(self): + invalid, labels, reasons = check_document_number_unifed( + "JPH11073839A", + "JP特開平11-73838A", + ) + + assert invalid is True + assert labels == ["incorrect_conversion"] + assert reasons == [ + "conversion from document_number 'JP特開平11-73838A' is incorrect: " + "expected 'JPH11073838A', got 'JPH11073839A'" + ] + + def test_rule_reports_invalid_document_number_unifed(self): + result = RulePatentFieldValidation().eval( + Data( + claims=[], + ipc_unified=[], + document_number="JP特開平11-73838A", + document_number_unifed="JPH11073839A", + ) + ) + + assert result.status is True + assert result.label == ["document_number_unifed.incorrect_conversion"] + assert result.reason == [ + "document_number_unifed: conversion from document_number " + "'JP特開平11-73838A' is incorrect: expected 'JPH11073838A', " + "got 'JPH11073839A'" + ] + + def test_missing_document_number_unifed_is_invalid(self): + result = RulePatentFieldValidation().eval( + Data( + claims=[], + ipc_unified=[], + document_number="JP2013022164A", + ) + ) + + assert result.status is True + assert result.label == ["document_number_unifed.missing_field"] + assert result.reason == ["document_number_unifed: missing field"]