From 3efdbc2b5b89dd9e1e728ff3060743eb3047af9f Mon Sep 17 00:00:00 2001 From: shijin Date: Tue, 27 Oct 2026 11:08:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20ipc=E8=BD=AC=E5=8C=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E3=80=81document=5Fnumber=5Funified=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dingo/model/rule/scibase/rule_patent.py | 249 +++++++++++++++-- docs/rules.md | 42 ++- docs/scibase/rule_patent_labels.md | 71 +++++ test/scripts/model/rule/test_rule_patent.py | 289 +++++++++++++++++--- 4 files changed, 583 insertions(+), 68 deletions(-) create mode 100644 docs/scibase/rule_patent_labels.md diff --git a/dingo/model/rule/scibase/rule_patent.py b/dingo/model/rule/scibase/rule_patent.py index c0f0abc4..85e4060e 100644 --- a/dingo/model/rule/scibase/rule_patent.py +++ b/dingo/model/rule/scibase/rule_patent.py @@ -1,5 +1,6 @@ import json import re +from collections import Counter from typing import Any, List from dingo.config.input_args import EvaluatorRuleArgs @@ -17,6 +18,25 @@ r"(?P[1-9][0-9]{0,2})/" r"(?P[0-9]{2,6})$" ) +IPC_SOURCE_CODE_RE = re.compile( + r"^(?:[1-7]\s*)?" + r"(?P[A-H]\s*(?:0[1-9]|[1-9][0-9])\s*[A-Z])\s*" + r"(?P[0-9]{1,3})\s*/\s*" + r"(?P[0-9]{2,6})(?:\s+[AB])?$" +) +IPC_FIXED_WIDTH_RE = re.compile( + r"^(?P[A-H](?:0[1-9]|[1-9][0-9])[A-Z])" + r"(?P[0-9]{3})(?P[0-9]{2})$" +) +IPC_FRAGMENT_RE = re.compile( + r"^(?P[0-9]{1,3})\s*/\s*" + r"(?P[0-9]{2,6})(?:\s+[AB])?$" +) +IPC_INCOMPLETE_RE = re.compile(r"^[A-H](?:(?:0[1-9]|[1-9][0-9])[A-Z]?)?$") +IPC_AMBIGUOUS_RE = re.compile( + r"^(?P[A-H]\s*(?:0[1-9]|[1-9][0-9])\s*[A-Z])" + r"(?P\s*)(?P[0-9]+)$" +) ASCII_ALNUM_RE = re.compile(r"^[A-Za-z0-9]+$") JP_ERA_CITATION_RE = re.compile( r"^JP(?P公表実用新案|実用新案公表|特開|特表|特公|実開|実表|実公)" @@ -63,6 +83,7 @@ "実用新案公表": "U", } ValidationResult = tuple[bool, List[str], List[str]] +IPC_SOURCE_NOT_PROVIDED = object() def check_claims(claims: Any) -> ValidationResult: @@ -79,7 +100,70 @@ def check_claims(claims: Any) -> ValidationResult: return False, [], [] -def check_ipc_unified(ipc_unified: Any) -> ValidationResult: +def _convert_ipc_code( + ipc_code: str, + previous_subclass: str | None, +) -> tuple[str, str | None, str | None, str | None]: + """Classify one source item and return its conversion and context. + + The first value is ``success``, ``removed`` or ``conversion_failed``. + A bare four-character subclass is the only explicitly removable source + form; unknown or ambiguous forms are never silently treated as removals. + """ + + source = ipc_code.strip().upper() + match = IPC_SOURCE_CODE_RE.fullmatch(source) + if match is None: + # Only the original, contiguous nine-character form is fixed-width. + # Removing spaces first would wrongly decode ambiguous US values. + match = IPC_FIXED_WIDTH_RE.fullmatch(source) + if match is not None: + subclass = re.sub(r"\s+", "", match.group("subclass")) + else: + compact = re.sub(r"\s+", "", source) + if IPC_SUBCLASS_RE.fullmatch(compact): + return "removed", None, compact, None + if IPC_INCOMPLETE_RE.fullmatch(compact): + return "conversion_failed", None, None, "unsupported_format" + ambiguous = IPC_AMBIGUOUS_RE.fullmatch(source) + if ambiguous is not None: + subclass = re.sub(r"\s+", "", ambiguous.group("subclass")) + error_code = ( + "ambiguous" + if ambiguous.group("separator") or len(ambiguous.group("digits")) < 6 + else "unsupported_format" + ) + return "conversion_failed", None, subclass, error_code + match = IPC_FRAGMENT_RE.fullmatch(source) + if match is None: + return "conversion_failed", None, None, "unsupported_format" + if previous_subclass is None: + return "conversion_failed", None, None, "missing_context" + subclass = previous_subclass + + main_group = str(int(match.group("main_group"))) + subgroup = match.group("subgroup") + # Preserve the existing zero-group convention accepted by this checker. + if main_group == "0" and int(subgroup) == 0: + return "success", subclass, subclass, None + expected = f"{subclass} {main_group}/{subgroup}" + if IPC_CODE_RE.fullmatch(expected) is None: + return "conversion_failed", None, None, "unsupported_format" + if subgroup != "00" and int(subgroup) == 0: + return "conversion_failed", None, None, "unsupported_format" + return "success", expected, subclass, None + + +def check_ipc_unified( + ipc_unified: Any, + ipc: Any = IPC_SOURCE_NOT_PROVIDED, +) -> ValidationResult: + """Validate output format and, when available, deterministic IPC conversions. + + Lists and JSON arrays are accepted for both fields. Without a source this + retains format-only validation. Conversion results are compared as + multisets, so order is ignored while duplicate counts are preserved. + """ if ipc_unified is None: return True, ["null"], ["value is null"] @@ -90,15 +174,16 @@ def check_ipc_unified(ipc_unified: Any) -> ValidationResult: return True, ["invalid_json"], ["value must be a JSON array"] if not isinstance(ipc_unified, list): - return True, ["wrong_type"], ["value must be a list"] + return True, ["not_array"], ["value must be a list"] error_labels: List[str] = [] reasons: List[str] = [] + valid_output_indices: dict[str, List[int]] = {} for index, ipc_code in enumerate(ipc_unified): if not isinstance(ipc_code, str): - if "wrong_type" not in error_labels: - error_labels.append("wrong_type") + if "item_not_string" not in error_labels: + error_labels.append("item_not_string") reasons.append(f"item[{index}] must be a string") continue @@ -106,14 +191,15 @@ def check_ipc_unified(ipc_unified: Any) -> ValidationResult: # normalized to the four-character subclass code (for example, # ``F03G 0/00`` becomes ``F03G``). if IPC_SUBCLASS_RE.fullmatch(ipc_code): + valid_output_indices.setdefault(ipc_code, []).append(index) continue match = IPC_CODE_RE.fullmatch(ipc_code) if match is None: - if "invalid_format" not in error_labels: - error_labels.append("invalid_format") + if "invalid_ipc_format" not in error_labels: + error_labels.append("invalid_ipc_format") reasons.append( - f"item[{index}] value {ipc_code!r} must match IPC format " + f"item[{index}] {ipc_code!r} must match IPC format " "'<01-99>' or " "'<01-99> <1-999>/<2-6 digits>'" ) @@ -121,12 +207,126 @@ def check_ipc_unified(ipc_unified: Any) -> ValidationResult: subgroup = match.group("subgroup") if subgroup != "00" and int(subgroup) == 0: - if "invalid_format" not in error_labels: - error_labels.append("invalid_format") + if "invalid_ipc_format" not in error_labels: + error_labels.append("invalid_ipc_format") reasons.append( - f"item[{index}] value {ipc_code!r} subgroup may be zero only " + f"item[{index}] {ipc_code!r} subgroup may be zero only " "when written as '00'" ) + continue + valid_output_indices.setdefault(ipc_code, []).append(index) + + if ipc is IPC_SOURCE_NOT_PROVIDED: + return bool(error_labels), error_labels, reasons + if ipc is None: + error_labels.append("source_invalid.null") + reasons.append("ipc is null") + return True, error_labels, reasons + if isinstance(ipc, str): + try: + ipc = json.loads(ipc, strict=False) + except json.JSONDecodeError: + error_labels.append("source_invalid.invalid_json") + reasons.append("ipc must be a JSON array") + return True, error_labels, reasons + if not isinstance(ipc, list): + error_labels.append("source_invalid.not_array") + reasons.append("ipc must be a list") + return True, error_labels, reasons + + expected_sources: dict[str, List[int]] = {} + comparison_is_resolved = True + previous_subclass = None + for index, source_code in enumerate(ipc): + if not isinstance(source_code, str): + if "source_invalid.item_not_string" not in error_labels: + error_labels.append("source_invalid.item_not_string") + reasons.append(f"ipc item[{index}] must be a string") + comparison_is_resolved = False + previous_subclass = None + continue + status, expected, previous_subclass, detail = _convert_ipc_code( + source_code, previous_subclass + ) + if status == "success": + expected_sources.setdefault(expected, []).append(index) + elif status == "conversion_failed": + label = f"conversion_failed.{detail}" + if label not in error_labels: + error_labels.append(label) + reason_detail = { + "ambiguous": "has more than one possible slashless group split", + "missing_context": "is a group fragment without preceding subclass context", + "unsupported_format": "uses a compact format that conversion does not support", + }[detail] + reasons.append(f"ipc[{index}] {source_code!r} {reason_detail}") + comparison_is_resolved = False + + expected_counts = Counter( + {value: len(source_indices) for value, source_indices in expected_sources.items()} + ) + actual_counts = Counter( + {value: len(indices) for value, indices in valid_output_indices.items()} + ) + total_valid_outputs = sum(actual_counts.values()) + + def add_label(label: str) -> None: + if label not in error_labels: + error_labels.append(label) + + missing_values = [ + value + for value, expected_count in expected_counts.items() + if actual_counts[value] < expected_count + ] + if missing_values and total_valid_outputs == 0: + add_label("missing.all") + for value in missing_values: + sources = ", ".join(f"ipc[{index}]" for index in expected_sources[value]) + reasons.append( + f"expected {value!r} {expected_counts[value]} time(s) from {sources}, " + "but ipc_unified contains no valid IPC items" + ) + else: + for value in missing_values: + expected_count = expected_counts[value] + actual_count = actual_counts[value] + sources = ", ".join(f"ipc[{index}]" for index in expected_sources[value]) + if actual_count == 0: + add_label("missing.value") + reasons.append( + f"expected {value!r} {expected_count} time(s) from {sources}, " + "but it is absent from ipc_unified" + ) + else: + add_label("missing.count") + reasons.append( + f"{value!r} expected {expected_count} time(s) from {sources}, " + f"found {actual_count}; missing {expected_count - actual_count}" + ) + + if comparison_is_resolved: + if not ipc and total_valid_outputs: + add_label("extra.empty_source") + for value, indices in valid_output_indices.items(): + for index in indices: + reasons.append(f"ipc_unified[{index}] {value!r}: source ipc is empty") + else: + for value, actual_count in actual_counts.items(): + expected_count = expected_counts[value] + if expected_count == 0: + add_label("extra.value") + for index in valid_output_indices[value]: + reasons.append( + f"ipc_unified[{index}] {value!r}: no source conversion produces this value" + ) + elif actual_count > expected_count: + add_label("extra.count") + extra_indices = valid_output_indices[value][expected_count:] + reasons.append( + f"{value!r} expected {expected_count} time(s), found {actual_count}; " + f"extra at {', '.join(f'ipc_unified[{index}]' for index in extra_indices)}" + ) return bool(error_labels), error_labels, reasons @@ -205,27 +405,27 @@ def _expected_jp_document_number(document_number: str) -> str | None: return None -def check_document_number_unifed( - document_number_unifed: Any, +def check_document_number_unified( + document_number_unified: Any, document_number: Any = None, ) -> ValidationResult: error_labels: List[str] = [] reasons: List[str] = [] - if not isinstance(document_number_unifed, str): + if not isinstance(document_number_unified, str): return True, ["wrong_type"], ["value must be a string"] - if ASCII_ALNUM_RE.fullmatch(document_number_unifed) is None: + if ASCII_ALNUM_RE.fullmatch(document_number_unified) 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: + if expected is not None and document_number_unified != 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}" + f"expected {expected!r}, got {document_number_unified!r}" ) return bool(error_labels), error_labels, reasons @@ -245,7 +445,7 @@ def check_prior_art_references(prior_art_references: Any) -> ValidationResult: "sequence", "srep_phase", "document_number", - "document_number_unifed", + "document_number_unified", } error_labels: List[str] = [] reasons: List[str] = [] @@ -268,15 +468,15 @@ def add_error_label(error_label: str) -> None: ) continue - invalid, nested_labels, nested_reasons = check_document_number_unifed( - reference["document_number_unifed"], + invalid, nested_labels, nested_reasons = check_document_number_unified( + reference["document_number_unified"], reference["document_number"], ) if invalid: for error_label in nested_labels: add_error_label(error_label) reasons.extend( - f"item[{index}].document_number_unifed: {reason}" + f"item[{index}].document_number_unified: {reason}" for reason in nested_reasons ) @@ -285,9 +485,12 @@ def add_error_label(error_label: str) -> None: 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"), + "ipc_unified": lambda record: check_ipc_unified( + record.get("ipc_unified"), + record["ipc"] if "ipc" in record else IPC_SOURCE_NOT_PROVIDED, + ), + "document_number_unified": lambda record: check_document_number_unified( + record.get("document_number_unified"), record.get("document_number"), ), "prior_art_references": lambda record: check_prior_art_references( diff --git a/docs/rules.md b/docs/rules.md index 0b40a64f..dc5ae45f 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -64,7 +64,7 @@ The specific rules for each quality metric are as follows: | RuleOnlyUrl | EFFECTIVENESS | Check whether content consists only of a URL. | | | RulePatternSearch | RELEVANCE | Search content using a user-provided pattern. | | | RulePIIDetection | SECURITY | Detect personally identifiable information in text. | | -| RulePatentFieldValidation | EFFECTIVENESS | Validate patent claims length and IPC classification-code format. | | +| RulePatentFieldValidation | EFFECTIVENESS | Validate patent metadata fields, including IPC format and conversion from ipc to ipc_unified. | | | RuleQuanliangFieldValidation | EFFECTIVENESS | Validate full-volume scientific metadata fields. | | | RuleResumeDateFormat | RESUME_DATE | Check whether a resume uses inconsistent date formats. | | | RuleResumeDetailedAddress | RESUME_PRIVACY | Check whether a resume contains a detailed address. | | @@ -94,3 +94,43 @@ The specific rules for each quality metric are as follows: | RuleWordNumber | EFFECTIVENESS | check whether the number of word in [20, 100000] | [Redpajama](https://www.together.ai/blog/redpajama-data-v2) [MAP-en](https://arxiv.org/abs/2405.19327) [Gopher](https://arxiv.org/abs/2112.11446) [Dolma](https://arxiv.org/abs/2402.00159) | | RuleWordSplit | FLUENCY | Check PDF content for abnormally split words. | | | RuleWordStuck | FLUENCY | Check whether words are abnormally joined together. | | + +`RulePatentFieldValidation` checks `ipc_unified` against the original `ipc` when +available. Both fields accept lists or JSON arrays. Deterministic conversions +include uppercasing, removing pre-IPC-8 edition prefixes (including `3`), merging +subclass spaces, removing main-group leading zeros and A/B suffixes, decoding +contiguous nine-character USPTO codes, and filling a fragment's subclass from +the immediately preceding source item. The existing zero-group reduction +(`F03G 0/00` to `F03G`) is retained. + +Validation labels are divided by stage: + +| Stage | Labels | +|---|---| +| Output validity | `ipc_unified.null`, `.invalid_json`, `.not_array`, `.item_not_string`, `.invalid_ipc_format` | +| Source validity | `ipc_unified.source_invalid.null`, `.invalid_json`, `.not_array`, `.item_not_string` | +| Conversion failure | `ipc_unified.conversion_failed.ambiguous`, `.missing_context`, `.unsupported_format` | +| Missing output | `ipc_unified.missing.all`, `.value`, `.count` | +| Extra output | `ipc_unified.extra.empty_source`, `.value`, `.count` | + +Converted values are compared as multisets: output order is ignored, while +duplicate counts are preserved. `missing.all` is exclusive with the other +missing labels. For each value, absence uses `missing.value`, a short duplicate +count uses `missing.count`, an unknown value uses `extra.value`, and excess +duplicates use `extra.count`. A wrong conversion therefore normally produces +both `missing.value` and `extra.value`. + +A bare subclass such as `A61K` is the only source form explicitly removed; it +may still provide context to a following fragment. Ambiguous slashless values +such as `H01R 1200`, context-free fragments such as `216/14`, and unsupported +compact formats such as `G05F001455` are conversion failures, not removals. +If the source structure is invalid or any source string remains unresolved, +known expected values are still checked for missing output, but extra-output +checks are suppressed because the unresolved item may account for them. Invalid +output elements receive only an output-validity label and are excluded from +missing/extra counts. + +Omitting `ipc` keeps the check in output-format-only mode for compatibility; +an explicitly null `ipc` is `source_invalid.null`. This checker does not modify +either field or validate `ipc_edition_statement`, and it validates syntax rather +than membership in an official IPC classification catalogue. diff --git a/docs/scibase/rule_patent_labels.md b/docs/scibase/rule_patent_labels.md new file mode 100644 index 00000000..2f2698af --- /dev/null +++ b/docs/scibase/rule_patent_labels.md @@ -0,0 +1,71 @@ +# RulePatentFieldValidation Label 说明 + +## 实现位置 + +[`rule_patent.py`](../../dingo/model/rule/scibase/rule_patent.py) + +## 标签数量统计 + +| 字段 | 标签数量 | +|---|---:| +| `claims` | 2 | +| `ipc_unified` | 19 | +| `document_number_unified` | 4 | +| `prior_art_references` | 5 | +| **合计** | **30** | + +下表中的 Label 名是字段标签后缀。规则最终输出时会添加字段名前缀, +例如 `ipc_unified` 字段的 `missing.value` 最终输出为 +`ipc_unified.missing.value`。 + +## claims + +| Label 名 | 中文名称 | 标签含义 | +|---|---|---| +| `missing_field` | 字段缺失 | 检查输入对象中是否存在 `claims` 字段。 | +| `too_long` | 内容过长 | 将字段值转换为字符串后,长度超过 1,000,000 个字符时标记。当前实现不校验字段类型或 claims 内部结构。 | + +## ipc_unified + +| Label 名 | 中文名称 | 标签含义 | +|---|---|---| +| `missing_field` | 字段缺失 | 检查输入对象中是否存在 `ipc_unified` 字段。 | +| `null` | 值为 null | `ipc_unified` 的值为 `null`。 | +| `invalid_json` | JSON 格式错误 | `ipc_unified` 是字符串,但无法解析为 JSON。 | +| `not_array` | 不是数组 | `ipc_unified` 解析后不是数组。 | +| `item_not_string` | 数组项不是字符串 | `ipc_unified` 数组中至少一个元素不是字符串;异常元素不参与缺失项或多余项计数。 | +| `invalid_ipc_format` | 统一 IPC 格式不符合要求 | `ipc_unified` 中至少一个字符串不符合目标格式:四位小类号 `<01-99>`,或 `<四位小类号> <1-999>/<2-6 位数字>`。分隔符必须是一个半角空格;主组不得补零;全零小组只能写成 `00`;`F03G 0/00` 应归一化为 `F03G`。 | +| `source_invalid.null` | 原始 IPC 为 null | 原始 `ipc` 字段存在,但值为 `null`。若原始 `ipc` 字段不存在,则仅检查 `ipc_unified` 格式,不产生此标签。 | +| `source_invalid.invalid_json` | 原始 IPC JSON 格式错误 | 原始 `ipc` 是字符串,但无法解析为 JSON。 | +| `source_invalid.not_array` | 原始 IPC 不是数组 | 原始 `ipc` 解析后不是数组。 | +| `source_invalid.item_not_string` | 原始 IPC 数组项不是字符串 | 原始 `ipc` 数组中至少一个元素不是字符串。已成功转换的其他元素仍可继续检查缺失项,但不再判定多余项。 | +| `conversion_failed.ambiguous` | 转换结果有歧义 | 原始 IPC 是无斜杠数字粘连形式,存在多个合理的主组/小组拆分结果,无法唯一转换。例如 `H01R 1200`。 | +| `conversion_failed.missing_context` | 缺少转换上下文 | 原始 IPC 是省略共享小类的数字片段,但前面没有可供继承的小类。例如数组第一项直接出现 `216/14`。 | +| `conversion_failed.unsupported_format` | 暂未支持该转换格式 | 原始 IPC 元素是字符串,但其写法不在当前转换规则覆盖范围内。该标签只表示转换器暂不支持,不表示原始 IPC 格式非法。例如 `G05F001455`。 | +| `missing.all` | 转换结果全部缺失 | 原始 `ipc` 至少能确定一个预期转换值,但 `ipc_unified` 中没有任何格式有效的 IPC 值。此标签与 `missing.value`、`missing.count` 互斥。 | +| `missing.value` | 转换值缺失 | `ipc_unified` 中存在其他有效 IPC 值,但某个由原始 `ipc` 确定的预期值一次也没有出现。 | +| `missing.count` | 转换值数量不足 | 某个预期值已在 `ipc_unified` 中出现,但实际出现次数少于原始 `ipc` 转换结果中的次数。 | +| `extra.empty_source` | 空来源存在多余值 | 原始 `ipc` 是空数组,但 `ipc_unified` 中存在格式有效的 IPC 值。 | +| `extra.value` | 存在非预期值 | `ipc_unified` 中某个格式有效的值不在原始 `ipc` 的确定转换结果中。只有全部来源项都已成功转换或按明确规则移除时才判定。 | +| `extra.count` | 转换值数量过多 | 某个值属于预期转换结果,但它在 `ipc_unified` 中的出现次数多于原始 `ipc` 转换结果中的次数。 | + +## document_number_unified + +> 字段名 `unifed` 沿用当前数据模型和实现中的既有拼写。 + +| Label 名 | 中文名称 | 标签含义 | +|---|---|---| +| `missing_field` | 字段缺失 | 检查输入对象中是否存在 `document_number_unified` 字段。 | +| `wrong_type` | 字段类型错误 | 值不是字符串时标记。 | +| `invalid_format` | 格式不符合要求 | 值包含 ASCII 字母和数字以外的字符时标记。 | +| `incorrect_conversion` | 文献号转换错误 | 当原始 `document_number` 是当前规则支持的日本专利文献号形式时,`document_number_unified` 与确定性转换结果不一致。 | + +## prior_art_references + +| Label 名 | 中文名称 | 标签含义 | +|---|---|---| +| `missing_field` | 字段或必需属性缺失 | 输入对象中不存在 `prior_art_references` 字段,或数组项缺少 `sequence`、`srep_phase`、`document_number`、`document_number_unified` 中的至少一个属性。 | +| `invalid_json` | JSON 格式错误 | 字段值是字符串,但无法解析为 JSON。 | +| `wrong_type` | 字段或数组项类型错误 | 字段解析后不是数组、数组项不是对象,或数组项中的 `document_number_unified` 不是字符串。 | +| `invalid_format` | 统一文献号格式不符合要求 | 数组项中的 `document_number_unified` 包含 ASCII 字母和数字以外的字符。 | +| `incorrect_conversion` | 文献号转换错误 | 数组项中的原始 `document_number` 可按当前日本专利规则确定转换结果,但 `document_number_unified` 与该结果不一致。 | diff --git a/test/scripts/model/rule/test_rule_patent.py b/test/scripts/model/rule/test_rule_patent.py index b6724e84..36807487 100644 --- a/test/scripts/model/rule/test_rule_patent.py +++ b/test/scripts/model/rule/test_rule_patent.py @@ -3,7 +3,13 @@ import pytest from dingo.io.input import Data -from dingo.model.rule.scibase.rule_patent import MAX_CLAIMS_LENGTH, RulePatentFieldValidation, check_document_number_unifed, check_ipc_unified, check_prior_art_references +from dingo.model.rule.scibase.rule_patent import ( + MAX_CLAIMS_LENGTH, + RulePatentFieldValidation, + check_document_number_unified, + check_ipc_unified, + check_prior_art_references, +) def _claims_with_string_length(length: int) -> list[dict]: @@ -21,7 +27,7 @@ def test_claims_at_maximum_length_is_valid(self): claims=claims, ipc_unified=[], document_number="JP2013022164A", - document_number_unifed="JP2013022164A", + document_number_unified="JP2013022164A", prior_art_references=[], ) ) @@ -38,7 +44,7 @@ def test_claims_over_maximum_length_is_invalid(self): claims=claims, ipc_unified=[], document_number="JP2013022164A", - document_number_unifed="JP2013022164A", + document_number_unified="JP2013022164A", prior_art_references=[], ) ) @@ -54,7 +60,7 @@ def test_missing_claims_is_invalid(self): Data( ipc_unified=[], document_number="JP2013022164A", - document_number_unifed="JP2013022164A", + document_number_unified="JP2013022164A", prior_art_references=[], ) ) @@ -98,8 +104,8 @@ def test_invalid_ipc_format(self, ipc_code): invalid, labels, reasons = check_ipc_unified([ipc_code]) assert invalid is True - assert labels == ["invalid_format"] - assert reasons[0].startswith(f"item[0] value {ipc_code!r}") + assert labels == ["invalid_ipc_format"] + assert reasons[0].startswith(f"item[0] {ipc_code!r}") def test_reports_every_invalid_item_with_its_value(self): invalid, labels, reasons = check_ipc_unified( @@ -107,12 +113,12 @@ def test_reports_every_invalid_item_with_its_value(self): ) assert invalid is True - assert labels == ["invalid_format"] + assert labels == ["invalid_ipc_format"] assert reasons == [ - "item[1] value 'C00C 5/27' must match IPC format " + "item[1] '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 " + "item[3] 'C00C 15/08' must match IPC format " "'<01-99>' or " "'<01-99> <1-999>/<2-6 digits>'", ] @@ -121,15 +127,15 @@ def test_wrong_type_reason_does_not_include_value(self): invalid, labels, reasons = check_ipc_unified([123]) assert invalid is True - assert labels == ["wrong_type"] + assert labels == ["item_not_string"] assert reasons == ["item[0] must be a string"] @pytest.mark.parametrize( ("ipc_unified", "expected_label"), [ (None, "null"), - ({"code": "A01A 1/00"}, "wrong_type"), - ([1], "wrong_type"), + ({"code": "A01A 1/00"}, "not_array"), + ([1], "item_not_string"), ("not-json", "invalid_json"), ], ) @@ -146,17 +152,212 @@ def test_rule_reports_invalid_ipc_unified(self): claims=[], ipc_unified=["A01A 01/00"], document_number="JP2013022164A", - document_number_unifed="JP2013022164A", + document_number_unified="JP2013022164A", prior_art_references=[], ) ) assert result.status is True - assert result.label == ["ipc_unified.invalid_format"] + assert result.label == ["ipc_unified.invalid_ipc_format"] assert result.reason[0].startswith("ipc_unified: item[0]") @pytest.mark.parametrize( - ("document_number", "document_number_unifed"), + ("ipc", "ipc_unified"), + [ + (["3C 06B 21/00 A"], ["C06B 21/00"]), + (["7H 04L 012/56 B"], ["H04L 12/56"]), + (["a61k 31/00"], ["A61K 31/00"]), + ([" C 25D 021/12 "], ["C25D 21/12"]), + (["C25D21/12", "C08F214/18"], ["C25D 21/12", "C08F 214/18"]), + (["G01B009/00"], ["G01B 9/00"]), + (["H01R01970", "D21C00712"], ["H01R 19/70", "D21C 7/12"]), + (["F03G 0/00"], ["F03G"]), + (["F03G00000"], ["F03G"]), + ( + ["C08F214/18", "216/14", "218/16"], + ["C08F 214/18", "C08F 216/14", "C08F 218/16"], + ), + (["A61K", "31/00"], ["A61K 31/00"]), + (["A61K"], []), + ([], []), + ], + ) + def test_valid_ipc_conversion(self, ipc, ipc_unified): + original = list(ipc) + unified = list(ipc_unified) + + assert check_ipc_unified(ipc_unified, ipc) == (False, [], []) + assert check_ipc_unified(json.dumps(ipc_unified), json.dumps(ipc)) == ( + False, + [], + [], + ) + assert ipc == original + assert ipc_unified == unified + + def test_conversion_comparison_ignores_order(self): + assert check_ipc_unified( + ["G01B 9/00", "C06B 21/00"], + ["3C 06B 21/00 A", "G01B009/00"], + ) == (False, [], []) + + def test_wrong_conversion_reports_missing_and_extra_values(self): + invalid, labels, reasons = check_ipc_unified( + ["C06B 22/00"], + ["3C 06B 21/00 A"], + ) + + assert invalid is True + assert labels == ["missing.value", "extra.value"] + assert any("expected 'C06B 21/00'" in reason for reason in reasons) + assert any("ipc_unified[0] 'C06B 22/00'" in reason for reason in reasons) + + @pytest.mark.parametrize("ipc_unified", [[], "[]"]) + def test_all_expected_values_missing(self, ipc_unified): + invalid, labels, reasons = check_ipc_unified( + ipc_unified, + ["C08F214/18", "216/14"], + ) + + assert invalid is True + assert labels == ["missing.all"] + assert len(reasons) == 2 + + def test_missing_value_and_missing_count_are_distinct(self): + invalid, labels, reasons = check_ipc_unified( + ["A61K 31/00"], + ["A61K 31/00", "A61K 31/00", "G01B009/00"], + ) + + assert invalid is True + assert labels == ["missing.count", "missing.value"] + assert any("missing 1" in reason for reason in reasons) + assert any("'G01B 9/00'" in reason for reason in reasons) + + @pytest.mark.parametrize("ipc", [[], "[]"]) + def test_extra_values_with_empty_source_have_specific_label(self, ipc): + assert check_ipc_unified(["A45F 5/02"], ipc) == ( + True, + ["extra.empty_source"], + ["ipc_unified[0] 'A45F 5/02': source ipc is empty"], + ) + + def test_extra_value_and_extra_count_are_distinct(self): + invalid, labels, reasons = check_ipc_unified( + ["A61K 31/00", "A61K 31/00", "G01B 9/00"], + ["A61K 31/00"], + ) + + assert invalid is True + assert labels == ["extra.count", "extra.value"] + assert any("extra at ipc_unified[1]" in reason for reason in reasons) + assert any("ipc_unified[2] 'G01B 9/00'" in reason for reason in reasons) + + def test_bare_subclass_is_an_explicit_removal_not_an_unknown_form(self): + assert check_ipc_unified([], ["A61K"]) == (False, [], []) + assert check_ipc_unified(["A61K"], ["A61K"]) == ( + True, + ["extra.value"], + ["ipc_unified[0] 'A61K': no source conversion produces this value"], + ) + + @pytest.mark.parametrize( + ("ipc", "expected_label"), + [ + (["H01R 1200"], "conversion_failed.ambiguous"), + (["G05F001455"], "conversion_failed.unsupported_format"), + (["A61"], "conversion_failed.unsupported_format"), + (["unrecognized"], "conversion_failed.unsupported_format"), + (["216/14"], "conversion_failed.missing_context"), + ], + ) + def test_conversion_failures_are_not_treated_as_removals( + self, ipc, expected_label + ): + invalid, labels, reasons = check_ipc_unified([], ipc) + + assert invalid is True + assert labels == [expected_label] + assert reasons + + @pytest.mark.parametrize( + ("ipc", "expected_label"), + [ + (None, "source_invalid.null"), + ("not-json", "source_invalid.invalid_json"), + ('{"code": "A61K 31/00"}', "source_invalid.not_array"), + (123, "source_invalid.not_array"), + ([None], "source_invalid.item_not_string"), + ], + ) + def test_invalid_source_ipc_is_reported(self, ipc, expected_label): + invalid, labels, reasons = check_ipc_unified([], ipc) + + assert invalid is True + assert labels == [expected_label] + assert reasons + + def test_unresolved_source_suppresses_extra_but_not_known_missing(self): + invalid, labels, reasons = check_ipc_unified( + ["C06B 22/00", "A45F 5/02"], + ["H01R 1200", "3C 06B 21/00 A"], + ) + + assert invalid is True + assert labels == ["conversion_failed.ambiguous", "missing.value"] + assert not any(label.startswith("extra.") for label in labels) + assert any("'C06B 21/00'" in reason for reason in reasons) + + def test_unsupported_source_format_suppresses_extra_but_not_known_missing(self): + invalid, labels, _ = check_ipc_unified( + ["A45F 5/02"], + ["unrecognized", "G01B009/00"], + ) + + assert invalid is True + assert labels == ["conversion_failed.unsupported_format", "missing.value"] + + def test_invalid_output_item_is_not_also_reported_as_extra(self): + invalid, labels, reasons = check_ipc_unified( + ["not-an-ipc"], + ["G01B009/00"], + ) + + assert invalid is True + assert labels == [ + "invalid_ipc_format", + "missing.all", + ] + assert not any(label.startswith("extra.") for label in labels) + assert len(reasons) == 2 + + def test_rule_prefixes_detailed_conversion_labels(self): + model = RulePatentFieldValidation() + model.dynamic_config = model.dynamic_config.model_copy(deep=True) + model.dynamic_config.key_list = ["ipc_unified"] + + result = model.eval( + Data(ipc=["3C 06B 21/00 A"], ipc_unified=["C06B 22/00"]) + ) + + assert result.status is True + assert result.label == [ + "ipc_unified.missing.value", + "ipc_unified.extra.value", + ] + assert all(reason.startswith("ipc_unified: ") for reason in result.reason) + + def test_rule_reports_null_source_but_missing_source_keeps_format_only_mode(self): + model = RulePatentFieldValidation() + model.dynamic_config = model.dynamic_config.model_copy(deep=True) + model.dynamic_config.key_list = ["ipc_unified"] + + null_result = model.eval(Data(ipc=None, ipc_unified=[])) + assert null_result.label == ["ipc_unified.source_invalid.null"] + assert model.eval(Data(ipc_unified=[])).status is False + + @pytest.mark.parametrize( + ("document_number", "document_number_unified"), [ ("JP特開平11-73838A", "JPH11073838A"), ("JP特開平5-263266A", "JPH05263266A"), @@ -188,15 +389,15 @@ def test_rule_reports_invalid_ipc_unified(self): def test_valid_document_number_conversion( self, document_number, - document_number_unifed, + document_number_unified, ): - assert check_document_number_unifed( - document_number_unifed, + assert check_document_number_unified( + document_number_unified, document_number, ) == (False, [], []) @pytest.mark.parametrize( - "document_number_unifed", + "document_number_unified", [ "", "JP特開平11-73838A", @@ -204,31 +405,31 @@ def test_valid_document_number_conversion( "JP2013022164 A", ], ) - def test_document_number_unifed_must_be_ascii_alphanumeric( + def test_document_number_unified_must_be_ascii_alphanumeric( self, - document_number_unifed, + document_number_unified, ): - invalid, labels, reasons = check_document_number_unifed( - document_number_unifed + invalid, labels, reasons = check_document_number_unified( + document_number_unified ) 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( + @pytest.mark.parametrize("document_number_unified", [None, 123, []]) + def test_document_number_unified_must_be_string( self, - document_number_unifed, + document_number_unified, ): - assert check_document_number_unifed(document_number_unifed) == ( + assert check_document_number_unified(document_number_unified) == ( True, ["wrong_type"], ["value must be a string"], ) def test_reports_incorrect_document_number_conversion(self): - invalid, labels, reasons = check_document_number_unifed( + invalid, labels, reasons = check_document_number_unified( "JPH11073839A", "JP特開平11-73838A", ) @@ -246,32 +447,32 @@ def test_jpwo_undefined_two_digit_year_is_not_guessed(self, year): # The document does not define a century for 27-29, so the checker # validates the unified field's format without inventing a conversion. - assert check_document_number_unifed(original, original) == ( + assert check_document_number_unified(original, original) == ( True, ["invalid_format"], ["value must contain only ASCII letters and digits"], ) - def test_rule_reports_invalid_document_number_unifed(self): + def test_rule_reports_invalid_document_number_unified(self): result = RulePatentFieldValidation().eval( Data( claims=[], ipc_unified=[], document_number="JP特開平11-73838A", - document_number_unifed="JPH11073839A", + document_number_unified="JPH11073839A", prior_art_references=[], ) ) assert result.status is True - assert result.label == ["document_number_unifed.incorrect_conversion"] + assert result.label == ["document_number_unified.incorrect_conversion"] assert result.reason == [ - "document_number_unifed: conversion from document_number " + "document_number_unified: conversion from document_number " "'JP特開平11-73838A' is incorrect: expected 'JPH11073838A', " "got 'JPH11073839A'" ] - def test_missing_document_number_unifed_is_invalid(self): + def test_missing_document_number_unified_is_invalid(self): result = RulePatentFieldValidation().eval( Data( claims=[], @@ -282,8 +483,8 @@ def test_missing_document_number_unifed_is_invalid(self): ) assert result.status is True - assert result.label == ["document_number_unifed.missing_field"] - assert result.reason == ["document_number_unifed: missing field"] + assert result.label == ["document_number_unified.missing_field"] + assert result.reason == ["document_number_unified: missing field"] def test_valid_prior_art_references(self): prior_art_references = [ @@ -291,13 +492,13 @@ def test_valid_prior_art_references(self): "sequence": "1", "srep_phase": "SEA", "document_number": "JP特開2002-15639(JP,A)A", - "document_number_unifed": "JP2002015639A", + "document_number_unified": "JP2002015639A", }, { "sequence": "2", "srep_phase": "SEA", "document_number": "JP特開平7-302526(JP,A)A", - "document_number_unifed": "JPH07302526A", + "document_number_unified": "JPH07302526A", }, ] @@ -318,13 +519,13 @@ def test_prior_art_references_reports_item_index(self): "sequence": "1", "srep_phase": "SEA", "document_number": "JP特開2002-15639(JP,A)A", - "document_number_unifed": "JP2002015639A", + "document_number_unified": "JP2002015639A", }, { "sequence": "2", "srep_phase": "SEA", "document_number": "JP特開平7-302526(JP,A)A", - "document_number_unifed": "JPH07302527A", + "document_number_unified": "JPH07302527A", }, ] ) @@ -332,7 +533,7 @@ def test_prior_art_references_reports_item_index(self): assert invalid is True assert labels == ["incorrect_conversion"] assert reasons == [ - "item[1].document_number_unifed: conversion from document_number " + "item[1].document_number_unified: conversion from document_number " "'JP特開平7-302526(JP,A)A' is incorrect: expected " "'JPH07302526A', got 'JPH07302527A'" ] @@ -369,13 +570,13 @@ def test_rule_reports_invalid_prior_art_reference(self): claims=[], ipc_unified=[], document_number="JP2013022164A", - document_number_unifed="JP2013022164A", + document_number_unified="JP2013022164A", prior_art_references=[ { "sequence": "1", "srep_phase": "SEA", "document_number": "JP特開平7-302526(JP,A)A", - "document_number_unifed": "JPH07302527A", + "document_number_unified": "JPH07302527A", } ], ) @@ -384,7 +585,7 @@ def test_rule_reports_invalid_prior_art_reference(self): assert result.status is True assert result.label == ["prior_art_references.incorrect_conversion"] assert result.reason == [ - "prior_art_references: item[0].document_number_unifed: conversion " + "prior_art_references: item[0].document_number_unified: conversion " "from document_number 'JP特開平7-302526(JP,A)A' is incorrect: " "expected 'JPH07302526A', got 'JPH07302527A'" ] From 587cf5801751f3259e7fc930bc7f2e0bfbd19516 Mon Sep 17 00:00:00 2001 From: shijin Date: Tue, 27 Oct 2026 11:28:37 +0800 Subject: [PATCH 2/2] feat: lint --- test/scripts/model/rule/test_rule_patent.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/scripts/model/rule/test_rule_patent.py b/test/scripts/model/rule/test_rule_patent.py index 36807487..92ce55c3 100644 --- a/test/scripts/model/rule/test_rule_patent.py +++ b/test/scripts/model/rule/test_rule_patent.py @@ -3,13 +3,7 @@ import pytest from dingo.io.input import Data -from dingo.model.rule.scibase.rule_patent import ( - MAX_CLAIMS_LENGTH, - RulePatentFieldValidation, - check_document_number_unified, - check_ipc_unified, - check_prior_art_references, -) +from dingo.model.rule.scibase.rule_patent import MAX_CLAIMS_LENGTH, RulePatentFieldValidation, check_document_number_unified, check_ipc_unified, check_prior_art_references def _claims_with_string_length(length: int) -> list[dict]: