From e4dfc9ea35870b53e5891c22689d76c08174e409 Mon Sep 17 00:00:00 2001 From: shijin Date: Sun, 20 Sep 2026 13:40:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=204=E4=B8=AAaccess=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=99=90=E5=88=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dingo/model/rule/scibase/rule_quanliang.py | 55 +++++++- docs/scibase/rule_quanliang_labels.md | 5 +- test/scripts/model/rule/test_rule_scibase.py | 126 +++++++++++++++++++ 3 files changed, 182 insertions(+), 4 deletions(-) diff --git a/dingo/model/rule/scibase/rule_quanliang.py b/dingo/model/rule/scibase/rule_quanliang.py index 73dc333b..7023fd67 100644 --- a/dingo/model/rule/scibase/rule_quanliang.py +++ b/dingo/model/rule/scibase/rule_quanliang.py @@ -430,6 +430,7 @@ OA_BOOL_VALUES = {"true", "false", "unknown"} METADATA_TYPE_VALUES = {"paper", "ebook"} OA_STATUS_VALUES = {"diamond", "gold", "green", "hybrid", "bronze", "closed", ""} +OPEN_OA_STATUS_VALUES = {"diamond", "gold", "green", "hybrid", "bronze"} LOC_TYPE_VALUES = {"download", "reader", "display", ""} JSON_LIST_FIELDS = { "isbns", @@ -1339,7 +1340,13 @@ def check_locations(locations: Any) -> ValidationResult: return _ok() -def check_access_is_oa(access_is_oa: Any, metadata_type: Any) -> ValidationResult: +def check_access_is_oa( + access_is_oa: Any, + metadata_type: Any, + access_oa_status: Any = None, + access_oa_url: Any = None, + access_license: Any = None, +) -> ValidationResult: if metadata_type not in METADATA_TYPE_VALUES: return _ok() required = metadata_type == "paper" @@ -1351,7 +1358,43 @@ def check_access_is_oa(access_is_oa: Any, metadata_type: Any) -> ValidationResul return _fail("empty", "value cannot be empty string when metadata_type='paper'") if required else _ok() if access_is_oa not in OA_BOOL_VALUES: return _fail("unsupported_value", f"unsupported value '{access_is_oa}'") - return _ok() + + error_labels: list[str] = [] + reasons: list[str] = [] + + # Only apply consistency rules to otherwise valid dependent values. Their + # own validators report null, type, enum and URL-format errors separately. + status_is_valid = isinstance(access_oa_status, str) and access_oa_status in OA_STATUS_VALUES + url_is_string_list = isinstance(access_oa_url, list) and all( + isinstance(item, str) for item in access_oa_url + ) + license_is_valid = isinstance(access_license, str) and access_license in ACCESS_LICENSE_VALUES + + if access_is_oa == "true": + if status_is_valid and access_oa_status not in OPEN_OA_STATUS_VALUES: + error_labels.append("oa_status_mismatch") + reasons.append( + "access_oa_status must be one of diamond, gold, green, hybrid or bronze " + "when access_is_oa='true'" + ) + if url_is_string_list and not any(URL_RE.fullmatch(item) for item in access_oa_url): + error_labels.append("oa_url_mismatch") + reasons.append("access_oa_url must contain at least one valid URL when access_is_oa='true'") + else: + expected_status = "closed" if access_is_oa == "false" else "" + if status_is_valid and access_oa_status != expected_status: + error_labels.append("oa_status_mismatch") + reasons.append( + f"access_oa_status must be '{expected_status}' when access_is_oa='{access_is_oa}'" + ) + if url_is_string_list and access_oa_url != []: + error_labels.append("oa_url_mismatch") + reasons.append(f"access_oa_url must be empty when access_is_oa='{access_is_oa}'") + if license_is_valid and access_license != "": + error_labels.append("license_mismatch") + reasons.append(f"access_license must be empty when access_is_oa='{access_is_oa}'") + + return bool(error_labels), error_labels, reasons def check_access_oa_status(access_oa_status: Any) -> ValidationResult: @@ -1772,7 +1815,13 @@ def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]: "author": lambda record: check_author(record.get("author")), "contributors": lambda record: check_contributors(record.get("contributors")), "locations": lambda record: check_locations(record.get("locations")), - "access_is_oa": lambda record: check_access_is_oa(record.get("access_is_oa"), record.get("metadata_type")), + "access_is_oa": lambda record: check_access_is_oa( + record.get("access_is_oa"), + record.get("metadata_type"), + record.get("access_oa_status"), + record.get("access_oa_url"), + record.get("access_license"), + ), "access_oa_status": lambda record: check_access_oa_status(record.get("access_oa_status")), "access_oa_url": lambda record: check_access_oa_url(record.get("access_oa_url")), "access_license": lambda record: check_access_license(record.get("access_license")), diff --git a/docs/scibase/rule_quanliang_labels.md b/docs/scibase/rule_quanliang_labels.md index b94e5473..09ad8ca6 100644 --- a/docs/scibase/rule_quanliang_labels.md +++ b/docs/scibase/rule_quanliang_labels.md @@ -18,7 +18,7 @@ | `author` | 10 | | `contributors` | 4 | | `locations` | 6 | -| `access_is_oa` | 4 | +| `access_is_oa` | 7 | | `access_oa_status` | 4 | | `access_oa_url` | 4 | | `access_license` | 4 | @@ -225,6 +225,9 @@ | `wrong_type` | 字段类型错误 | 值的类型不是字符串时标记。 | | `empty` | 值为空 | 若论文开放获取标记为空,则标记。 | | `unsupported_value` | 值不在限定范围内 | 值须属于 3 个限定值之一:`true`、`false`、`unknown`。 | +| `oa_status_mismatch` | OA 状态不一致 | `true` 时状态须为 `diamond`、`gold`、`green`、`hybrid` 或 `bronze`;`false` 时须为 `closed`;`unknown` 时须为空。 | +| `oa_url_mismatch` | OA URL 不一致 | `true` 时须至少包含一个有效 OA URL;`false` 或 `unknown` 时须为空数组。 | +| `license_mismatch` | 许可证不一致 | `false` 或 `unknown` 时 `access_license` 须为空;`true` 时允许为空或使用受支持的许可证值。 | ## access_oa_status diff --git a/test/scripts/model/rule/test_rule_scibase.py b/test/scripts/model/rule/test_rule_scibase.py index 12116674..a3ee89ad 100644 --- a/test/scripts/model/rule/test_rule_scibase.py +++ b/test/scripts/model/rule/test_rule_scibase.py @@ -6,6 +6,132 @@ class TestRuleQuanliangFieldValidation: + def test_access_oa_consistency_valid_combinations(self): + cases = [ + { + "access_is_oa": "true", + "access_oa_status": "gold", + "access_oa_url": ["https://example.com/paper.pdf"], + "access_license": "", + }, + { + "access_is_oa": "true", + "access_oa_status": "green", + "access_oa_url": ["https://example.com/paper.pdf"], + "access_license": "cc-by", + }, + { + "access_is_oa": "false", + "access_oa_status": "closed", + "access_oa_url": [], + "access_license": "", + }, + { + "access_is_oa": "unknown", + "access_oa_status": "", + "access_oa_url": [], + "access_license": "", + }, + ] + + for case in cases: + model = RuleQuanliangFieldValidation() + model.dynamic_config = model.dynamic_config.model_copy(deep=True) + model.dynamic_config.key_list = [ + "access_is_oa", + "access_oa_status", + "access_oa_url", + "access_license", + ] + + result = model.eval(Data(metadata_type="paper", **case)) + + assert result.status is False, case + assert result.label == ["QUALITY_GOOD"], case + + def test_access_oa_consistency_reports_all_mismatches(self): + cases = [ + ( + { + "access_is_oa": "true", + "access_oa_status": "closed", + "access_oa_url": [], + "access_license": "", + }, + [ + "access_is_oa.oa_status_mismatch", + "access_is_oa.oa_url_mismatch", + ], + ), + ( + { + "access_is_oa": "false", + "access_oa_status": "gold", + "access_oa_url": ["https://example.com/paper.pdf"], + "access_license": "cc-by", + }, + [ + "access_is_oa.oa_status_mismatch", + "access_is_oa.oa_url_mismatch", + "access_is_oa.license_mismatch", + ], + ), + ( + { + "access_is_oa": "unknown", + "access_oa_status": "closed", + "access_oa_url": ["https://example.com/paper.pdf"], + "access_license": "mit", + }, + [ + "access_is_oa.oa_status_mismatch", + "access_is_oa.oa_url_mismatch", + "access_is_oa.license_mismatch", + ], + ), + ] + + for case, expected_labels in cases: + model = RuleQuanliangFieldValidation() + model.dynamic_config = model.dynamic_config.model_copy(deep=True) + model.dynamic_config.key_list = [ + "access_is_oa", + "access_oa_status", + "access_oa_url", + "access_license", + ] + + result = model.eval(Data(metadata_type="paper", **case)) + + assert result.status is True, case + assert result.label == expected_labels, case + + def test_access_oa_consistency_does_not_duplicate_basic_field_errors(self): + model = RuleQuanliangFieldValidation() + model.dynamic_config = model.dynamic_config.model_copy(deep=True) + model.dynamic_config.key_list = [ + "access_is_oa", + "access_oa_status", + "access_oa_url", + "access_license", + ] + + result = model.eval( + Data( + metadata_type="paper", + access_is_oa="false", + access_oa_status=None, + access_oa_url="not-a-list", + access_license="not-a-license", + ) + ) + + assert result.label == [ + "access_oa_status.null", + "access_oa_url.wrong_type", + "access_license.unsupported_value", + ] + def test_author_quality_labels(self): model = RuleQuanliangFieldValidation() model.dynamic_config = model.dynamic_config.model_copy(deep=True)