igerasimov's picture
MVP Milestone 3
413d5a1
Raw
History Blame Contribute Delete
6.78 kB
"""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)