Spaces:
Running on Zero
Running on Zero
| """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) | |