| """Deterministic loading and validation for source article records.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| from collections import Counter |
| from pathlib import Path |
| from typing import Any |
|
|
| from pydantic import ValidationError |
|
|
| from gcmd_classifier.errors import ( |
| ArticleFileNotFoundError, |
| ArticleJSONDecodeError, |
| ArticleTopLevelTypeError, |
| ) |
| from gcmd_classifier.models import ArticleLoadResult, ArticleRecord, ArticleValidationIssue |
|
|
| REQUIRED_FIELDS = ("DOI", "Title", "Year", "Abstract") |
| _STRING_FIELDS = ("DOI", "Title", "Abstract") |
| _NON_EMPTY_STRING_FIELDS = ("DOI", "Title") |
|
|
|
|
| def read_article_json(path: str | Path) -> Any: |
| """Read and decode an article JSON file without validating record contents.""" |
| article_path = Path(path) |
| try: |
| raw_bytes = article_path.read_bytes() |
| except FileNotFoundError as exc: |
| raise ArticleFileNotFoundError(f"Article file not found: {article_path}") from exc |
| try: |
| return json.loads(raw_bytes) |
| except json.JSONDecodeError as exc: |
| raise ArticleJSONDecodeError(f"Invalid JSON in article file: {article_path}") from exc |
|
|
|
|
| def validate_article_record( |
| raw_record: Any, index: int |
| ) -> tuple[ArticleRecord | None, tuple[ArticleValidationIssue, ...]]: |
| """Validate one article record, preserving source values exactly on success.""" |
| issues: list[ArticleValidationIssue] = [] |
| if not isinstance(raw_record, dict): |
| return None, ( |
| ArticleValidationIssue( |
| index=index, |
| code="ARTICLE_NOT_OBJECT", |
| message="Article entry must be a JSON object.", |
| ), |
| ) |
|
|
| for field in REQUIRED_FIELDS: |
| if field not in raw_record: |
| issues.append( |
| ArticleValidationIssue( |
| index=index, |
| code="MISSING_REQUIRED_FIELD", |
| field=field, |
| message=f"Article record is missing required field {field!r}.", |
| DOI=_safe_doi(raw_record), |
| ) |
| ) |
|
|
| for field in _STRING_FIELDS: |
| if field in raw_record and not isinstance(raw_record[field], str): |
| issues.append( |
| ArticleValidationIssue( |
| index=index, |
| code="INVALID_FIELD_TYPE", |
| field=field, |
| message=f"Article field {field!r} must be a string.", |
| DOI=_safe_doi(raw_record), |
| ) |
| ) |
|
|
| for field in _NON_EMPTY_STRING_FIELDS: |
| if field in raw_record and isinstance(raw_record[field], str) and not raw_record[field]: |
| issues.append( |
| ArticleValidationIssue( |
| index=index, |
| code="EMPTY_REQUIRED_STRING", |
| field=field, |
| message=f"Article field {field!r} must be a non-empty string.", |
| DOI=_safe_doi(raw_record), |
| ) |
| ) |
|
|
| if "Year" in raw_record: |
| year = raw_record["Year"] |
| if isinstance(year, bool) or not isinstance(year, int): |
| issues.append( |
| ArticleValidationIssue( |
| index=index, |
| code="INVALID_FIELD_TYPE", |
| field="Year", |
| message="Article field 'Year' must be a strict integer and not a Boolean.", |
| DOI=_safe_doi(raw_record), |
| ) |
| ) |
|
|
| unexpected_fields = tuple(field for field in raw_record if field not in REQUIRED_FIELDS) |
| for field in unexpected_fields: |
| issues.append( |
| ArticleValidationIssue( |
| index=index, |
| code="UNEXPECTED_FIELD", |
| field=field, |
| message=f"Unexpected article field {field!r} is not allowed in the MVP.", |
| DOI=_safe_doi(raw_record), |
| ) |
| ) |
|
|
| if issues: |
| return None, tuple(issues) |
|
|
| try: |
| return ArticleRecord.model_validate(raw_record), () |
| except ValidationError as exc: |
| return None, tuple(_issues_from_pydantic_error(exc, raw_record=raw_record, index=index)) |
|
|
|
|
| def validate_article_records(raw_records: Any) -> ArticleLoadResult: |
| """Validate a top-level article list and collect all record-level validation errors.""" |
| if not isinstance(raw_records, list): |
| raise ArticleTopLevelTypeError("Article source JSON must be a top-level list.") |
|
|
| valid_candidates: list[tuple[int, ArticleRecord]] = [] |
| errors: list[ArticleValidationIssue] = [] |
| for index, raw_record in enumerate(raw_records): |
| article, issues = validate_article_record(raw_record, index) |
| errors.extend(issues) |
| if article is not None: |
| valid_candidates.append((index, article)) |
|
|
| doi_counts = Counter(article.DOI for _, article in valid_candidates) |
| duplicate_dois = {doi for doi, count in doi_counts.items() if count > 1} |
| if duplicate_dois: |
| unique_candidates: list[tuple[int, ArticleRecord]] = [] |
| for index, article in valid_candidates: |
| if article.DOI in duplicate_dois: |
| errors.append( |
| ArticleValidationIssue( |
| index=index, |
| code="DUPLICATE_DOI", |
| field="DOI", |
| message=f"Duplicate DOI value {article.DOI!r}.", |
| DOI=article.DOI, |
| ) |
| ) |
| else: |
| unique_candidates.append((index, article)) |
| valid_candidates = unique_candidates |
|
|
| return ArticleLoadResult( |
| articles=tuple(article for _, article in valid_candidates), |
| errors=tuple(errors), |
| source_count=len(raw_records), |
| ) |
|
|
|
|
| def load_articles(path: str | Path) -> ArticleLoadResult: |
| """Read and validate article records from a JSON file.""" |
| return validate_article_records(read_article_json(path)) |
|
|
|
|
| def _safe_doi(raw_record: dict[str, Any]) -> str | None: |
| value = raw_record.get("DOI") |
| return value if isinstance(value, str) else None |
|
|
|
|
| def _issues_from_pydantic_error( |
| exc: ValidationError, *, raw_record: dict[str, Any], index: int |
| ) -> tuple[ArticleValidationIssue, ...]: |
| issues: list[ArticleValidationIssue] = [] |
| for error in exc.errors(): |
| location = error.get("loc", ()) |
| field = str(location[0]) if location else None |
| issues.append( |
| ArticleValidationIssue( |
| index=index, |
| code="MODEL_VALIDATION_ERROR", |
| field=field, |
| message=str(error.get("msg", "Article model validation failed.")), |
| DOI=_safe_doi(raw_record), |
| ) |
| ) |
| return tuple(issues) |
|
|