Spaces:
Running on Zero
Running on Zero
Commit ·
413d5a1
1
Parent(s): 0f2ecac
MVP Milestone 3
Browse files- IMPLEMENTATION_PLAN.md +4 -4
- MVP_PROGRESS.md +81 -0
- src/gcmd_classifier/articles/__init__.py +15 -0
- src/gcmd_classifier/articles/loader.py +186 -0
- src/gcmd_classifier/errors.py +16 -0
- src/gcmd_classifier/models.py +43 -0
- tests/fixtures/articles_invalid.json +102 -0
- tests/fixtures/articles_valid.json +20 -0
- tests/test_articles.py +246 -0
IMPLEMENTATION_PLAN.md
CHANGED
|
@@ -609,7 +609,7 @@ Included requirements: `PROJECT_SPEC.md / Dataset Specification / Article Datase
|
|
| 609 |
Tasks:
|
| 610 |
|
| 611 |
- Implement article model preserving exact source field names.
|
| 612 |
-
- Validate required keys, field types, non-empty strings, and DOI uniqueness.
|
| 613 |
- Report invalid records without modifying source records.
|
| 614 |
- Add valid and invalid article fixtures.
|
| 615 |
|
|
@@ -619,14 +619,14 @@ Tests:
|
|
| 619 |
|
| 620 |
- Valid article loading.
|
| 621 |
- Missing required fields.
|
| 622 |
-
- Empty DOI
|
| 623 |
-
- Non-integer Year.
|
| 624 |
- Duplicate DOI.
|
| 625 |
- Source values preserved exactly.
|
| 626 |
|
| 627 |
Acceptance criteria:
|
| 628 |
|
| 629 |
-
- Current full-data smoke test confirms `data/articles.json`
|
| 630 |
- Invalid fixtures produce explicit errors.
|
| 631 |
- No normalization or rewriting occurs.
|
| 632 |
|
|
|
|
| 609 |
Tasks:
|
| 610 |
|
| 611 |
- Implement article model preserving exact source field names.
|
| 612 |
+
- Validate required keys, strict field types, non-empty DOI and Title strings, and DOI uniqueness. `Abstract` is required and must be a string, but may be empty.
|
| 613 |
- Report invalid records without modifying source records.
|
| 614 |
- Add valid and invalid article fixtures.
|
| 615 |
|
|
|
|
| 619 |
|
| 620 |
- Valid article loading.
|
| 621 |
- Missing required fields.
|
| 622 |
+
- Empty DOI and Title. Empty `Abstract` is allowed and must be preserved exactly.
|
| 623 |
+
- Non-integer Year, including Boolean values.
|
| 624 |
- Duplicate DOI.
|
| 625 |
- Source values preserved exactly.
|
| 626 |
|
| 627 |
Acceptance criteria:
|
| 628 |
|
| 629 |
+
- Current full-data smoke test confirms `data/articles.json` contains 468 source records, 467 records with valid non-empty unique DOI values, exactly one invalid record at index 467 due to empty DOI, and 14 otherwise valid records with empty `Abstract` values.
|
| 630 |
- Invalid fixtures produce explicit errors.
|
| 631 |
- No normalization or rewriting occurs.
|
| 632 |
|
MVP_PROGRESS.md
CHANGED
|
@@ -100,3 +100,84 @@ Deviations from approved plan:
|
|
| 100 |
Remaining risks or assumptions:
|
| 101 |
- The loader remains fully dynamic and does not hard-code vocabulary names, UUIDs, paths, branches, or record counts, but full-data smoke tests intentionally assert current-file counts.
|
| 102 |
- Future approved vocabulary rules would be needed before accepting any UUID-less non-root hierarchy node.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
Remaining risks or assumptions:
|
| 101 |
- The loader remains fully dynamic and does not hard-code vocabulary names, UUIDs, paths, branches, or record counts, but full-data smoke tests intentionally assert current-file counts.
|
| 102 |
- Future approved vocabulary rules would be needed before accepting any UUID-less non-root hierarchy node.
|
| 103 |
+
|
| 104 |
+
## Milestone 3: Article Loading and Validation
|
| 105 |
+
|
| 106 |
+
Status: completed
|
| 107 |
+
|
| 108 |
+
Scope completed:
|
| 109 |
+
- Deterministic loader for `data/articles.json`
|
| 110 |
+
- Strict source article model preserving exact serialized fields: `DOI`, `Title`, `Year`, and `Abstract`
|
| 111 |
+
- Read/decode, individual record validation, duplicate DOI detection, and aggregate result return paths
|
| 112 |
+
- Record-level validation errors with source index, code, field, message, and DOI when available
|
| 113 |
+
- Valid and invalid article fixtures
|
| 114 |
+
- Full-data smoke test for the current `data/articles.json`
|
| 115 |
+
|
| 116 |
+
Implemented article model and loader APIs:
|
| 117 |
+
- `ArticleRecord` in `src/gcmd_classifier/models.py`
|
| 118 |
+
- `ArticleValidationIssue` and `ArticleLoadResult` in `src/gcmd_classifier/models.py`
|
| 119 |
+
- `read_article_json`, `validate_article_record`, `validate_article_records`, and `load_articles` in `src/gcmd_classifier/articles/loader.py`
|
| 120 |
+
|
| 121 |
+
Validation and error-collection policy:
|
| 122 |
+
- File-level conditions raise typed exceptions: missing file, invalid JSON, and top-level JSON value that is not a list.
|
| 123 |
+
- Record-level conditions are collected in `ArticleLoadResult.errors` while all remaining records continue to be validated.
|
| 124 |
+
- Duplicate DOI records are reported with `DUPLICATE_DOI` and excluded from `ArticleLoadResult.articles` so returned records have unique DOI values.
|
| 125 |
+
|
| 126 |
+
Extra-field policy:
|
| 127 |
+
- Unexpected fields are rejected for the MVP with `UNEXPECTED_FIELD` because neither `PROJECT_SPEC.md` nor `AGENTS.md` defines an allow-list extension policy for source article records.
|
| 128 |
+
|
| 129 |
+
Exact-preservation protections:
|
| 130 |
+
- Successful `ArticleRecord` values are returned unchanged.
|
| 131 |
+
- DOI values are not normalized or replaced.
|
| 132 |
+
- Titles and abstracts are not stripped, lowercased, rewritten, summarized, or supplemented.
|
| 133 |
+
- Empty `Abstract` values are valid and preserved exactly.
|
| 134 |
+
- Whitespace-only strings are preserved; non-empty checks do not trim source values.
|
| 135 |
+
- `Year` must be a strict integer and Boolean values are rejected.
|
| 136 |
+
|
| 137 |
+
Current full-data findings:
|
| 138 |
+
- `data/articles.json` contains 468 source records.
|
| 139 |
+
- 467 records have valid non-empty unique DOI values.
|
| 140 |
+
- Exactly one record, index 467, is invalid because `DOI` is empty.
|
| 141 |
+
- 14 records have empty `Abstract` values and otherwise load successfully.
|
| 142 |
+
|
| 143 |
+
Error cases covered:
|
| 144 |
+
- File not found
|
| 145 |
+
- Invalid JSON
|
| 146 |
+
- Top-level JSON value not a list
|
| 147 |
+
- Article entry not an object
|
| 148 |
+
- Missing `DOI`, `Title`, `Year`, or `Abstract`
|
| 149 |
+
- Empty `DOI` and `Title`
|
| 150 |
+
- Non-string `DOI`, `Title`, or `Abstract`
|
| 151 |
+
- String, floating-point, and Boolean `Year`
|
| 152 |
+
- Null field values
|
| 153 |
+
- Duplicate DOI
|
| 154 |
+
- Unexpected extra fields
|
| 155 |
+
- Multiple invalid records reported in one aggregate result
|
| 156 |
+
|
| 157 |
+
Verification:
|
| 158 |
+
- `python -m ruff check .` passed: `All checks passed!`
|
| 159 |
+
- `python -m ruff format --check .` passed: `11 files already formatted`
|
| 160 |
+
- `python -m pytest` passed: `51 passed in 0.18s`
|
| 161 |
+
|
| 162 |
+
Out of scope, not started:
|
| 163 |
+
- Model prompts
|
| 164 |
+
- Model-provider integration
|
| 165 |
+
- Topic routing
|
| 166 |
+
- Term routing
|
| 167 |
+
- Variable-level classification
|
| 168 |
+
- Persistence
|
| 169 |
+
- Caching
|
| 170 |
+
- Batch processing
|
| 171 |
+
- UI and root `app.py`
|
| 172 |
+
|
| 173 |
+
Source-file status:
|
| 174 |
+
- `data/articles.json` unchanged by this milestone.
|
| 175 |
+
- `data/gcmd_hierarchy.json` unchanged by this milestone.
|
| 176 |
+
- `prototype/app_hf_poc.py` unchanged by this milestone.
|
| 177 |
+
|
| 178 |
+
Deviations from approved plan:
|
| 179 |
+
- `IMPLEMENTATION_PLAN.md` was revised to reflect the clarified Milestone 3 rule that empty `Abstract` values are valid.
|
| 180 |
+
|
| 181 |
+
Remaining risks or assumptions:
|
| 182 |
+
- The current source file remains partially invalid until the empty DOI at index 467 is corrected.
|
| 183 |
+
- Whitespace-only DOI or Title values are non-empty and therefore preserved as valid under the explicit no-trimming rule; this can be tightened later only with an approved source-data rule.
|
src/gcmd_classifier/articles/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Article loading and validation for source journal records."""
|
| 2 |
+
|
| 3 |
+
from gcmd_classifier.articles.loader import (
|
| 4 |
+
load_articles,
|
| 5 |
+
read_article_json,
|
| 6 |
+
validate_article_record,
|
| 7 |
+
validate_article_records,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"load_articles",
|
| 12 |
+
"read_article_json",
|
| 13 |
+
"validate_article_record",
|
| 14 |
+
"validate_article_records",
|
| 15 |
+
]
|
src/gcmd_classifier/articles/loader.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic loading and validation for source article records."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from pydantic import ValidationError
|
| 11 |
+
|
| 12 |
+
from gcmd_classifier.errors import (
|
| 13 |
+
ArticleFileNotFoundError,
|
| 14 |
+
ArticleJSONDecodeError,
|
| 15 |
+
ArticleTopLevelTypeError,
|
| 16 |
+
)
|
| 17 |
+
from gcmd_classifier.models import ArticleLoadResult, ArticleRecord, ArticleValidationIssue
|
| 18 |
+
|
| 19 |
+
REQUIRED_FIELDS = ("DOI", "Title", "Year", "Abstract")
|
| 20 |
+
_STRING_FIELDS = ("DOI", "Title", "Abstract")
|
| 21 |
+
_NON_EMPTY_STRING_FIELDS = ("DOI", "Title")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def read_article_json(path: str | Path) -> Any:
|
| 25 |
+
"""Read and decode an article JSON file without validating record contents."""
|
| 26 |
+
article_path = Path(path)
|
| 27 |
+
try:
|
| 28 |
+
raw_bytes = article_path.read_bytes()
|
| 29 |
+
except FileNotFoundError as exc:
|
| 30 |
+
raise ArticleFileNotFoundError(f"Article file not found: {article_path}") from exc
|
| 31 |
+
try:
|
| 32 |
+
return json.loads(raw_bytes)
|
| 33 |
+
except json.JSONDecodeError as exc:
|
| 34 |
+
raise ArticleJSONDecodeError(f"Invalid JSON in article file: {article_path}") from exc
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def validate_article_record(
|
| 38 |
+
raw_record: Any, index: int
|
| 39 |
+
) -> tuple[ArticleRecord | None, tuple[ArticleValidationIssue, ...]]:
|
| 40 |
+
"""Validate one article record, preserving source values exactly on success."""
|
| 41 |
+
issues: list[ArticleValidationIssue] = []
|
| 42 |
+
if not isinstance(raw_record, dict):
|
| 43 |
+
return None, (
|
| 44 |
+
ArticleValidationIssue(
|
| 45 |
+
index=index,
|
| 46 |
+
code="ARTICLE_NOT_OBJECT",
|
| 47 |
+
message="Article entry must be a JSON object.",
|
| 48 |
+
),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
for field in REQUIRED_FIELDS:
|
| 52 |
+
if field not in raw_record:
|
| 53 |
+
issues.append(
|
| 54 |
+
ArticleValidationIssue(
|
| 55 |
+
index=index,
|
| 56 |
+
code="MISSING_REQUIRED_FIELD",
|
| 57 |
+
field=field,
|
| 58 |
+
message=f"Article record is missing required field {field!r}.",
|
| 59 |
+
DOI=_safe_doi(raw_record),
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
for field in _STRING_FIELDS:
|
| 64 |
+
if field in raw_record and not isinstance(raw_record[field], str):
|
| 65 |
+
issues.append(
|
| 66 |
+
ArticleValidationIssue(
|
| 67 |
+
index=index,
|
| 68 |
+
code="INVALID_FIELD_TYPE",
|
| 69 |
+
field=field,
|
| 70 |
+
message=f"Article field {field!r} must be a string.",
|
| 71 |
+
DOI=_safe_doi(raw_record),
|
| 72 |
+
)
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
for field in _NON_EMPTY_STRING_FIELDS:
|
| 76 |
+
if field in raw_record and isinstance(raw_record[field], str) and not raw_record[field]:
|
| 77 |
+
issues.append(
|
| 78 |
+
ArticleValidationIssue(
|
| 79 |
+
index=index,
|
| 80 |
+
code="EMPTY_REQUIRED_STRING",
|
| 81 |
+
field=field,
|
| 82 |
+
message=f"Article field {field!r} must be a non-empty string.",
|
| 83 |
+
DOI=_safe_doi(raw_record),
|
| 84 |
+
)
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
if "Year" in raw_record:
|
| 88 |
+
year = raw_record["Year"]
|
| 89 |
+
if isinstance(year, bool) or not isinstance(year, int):
|
| 90 |
+
issues.append(
|
| 91 |
+
ArticleValidationIssue(
|
| 92 |
+
index=index,
|
| 93 |
+
code="INVALID_FIELD_TYPE",
|
| 94 |
+
field="Year",
|
| 95 |
+
message="Article field 'Year' must be a strict integer and not a Boolean.",
|
| 96 |
+
DOI=_safe_doi(raw_record),
|
| 97 |
+
)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
unexpected_fields = tuple(field for field in raw_record if field not in REQUIRED_FIELDS)
|
| 101 |
+
for field in unexpected_fields:
|
| 102 |
+
issues.append(
|
| 103 |
+
ArticleValidationIssue(
|
| 104 |
+
index=index,
|
| 105 |
+
code="UNEXPECTED_FIELD",
|
| 106 |
+
field=field,
|
| 107 |
+
message=f"Unexpected article field {field!r} is not allowed in the MVP.",
|
| 108 |
+
DOI=_safe_doi(raw_record),
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if issues:
|
| 113 |
+
return None, tuple(issues)
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
return ArticleRecord.model_validate(raw_record), ()
|
| 117 |
+
except ValidationError as exc:
|
| 118 |
+
return None, tuple(_issues_from_pydantic_error(exc, raw_record=raw_record, index=index))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def validate_article_records(raw_records: Any) -> ArticleLoadResult:
|
| 122 |
+
"""Validate a top-level article list and collect all record-level validation errors."""
|
| 123 |
+
if not isinstance(raw_records, list):
|
| 124 |
+
raise ArticleTopLevelTypeError("Article source JSON must be a top-level list.")
|
| 125 |
+
|
| 126 |
+
valid_candidates: list[tuple[int, ArticleRecord]] = []
|
| 127 |
+
errors: list[ArticleValidationIssue] = []
|
| 128 |
+
for index, raw_record in enumerate(raw_records):
|
| 129 |
+
article, issues = validate_article_record(raw_record, index)
|
| 130 |
+
errors.extend(issues)
|
| 131 |
+
if article is not None:
|
| 132 |
+
valid_candidates.append((index, article))
|
| 133 |
+
|
| 134 |
+
doi_counts = Counter(article.DOI for _, article in valid_candidates)
|
| 135 |
+
duplicate_dois = {doi for doi, count in doi_counts.items() if count > 1}
|
| 136 |
+
if duplicate_dois:
|
| 137 |
+
unique_candidates: list[tuple[int, ArticleRecord]] = []
|
| 138 |
+
for index, article in valid_candidates:
|
| 139 |
+
if article.DOI in duplicate_dois:
|
| 140 |
+
errors.append(
|
| 141 |
+
ArticleValidationIssue(
|
| 142 |
+
index=index,
|
| 143 |
+
code="DUPLICATE_DOI",
|
| 144 |
+
field="DOI",
|
| 145 |
+
message=f"Duplicate DOI value {article.DOI!r}.",
|
| 146 |
+
DOI=article.DOI,
|
| 147 |
+
)
|
| 148 |
+
)
|
| 149 |
+
else:
|
| 150 |
+
unique_candidates.append((index, article))
|
| 151 |
+
valid_candidates = unique_candidates
|
| 152 |
+
|
| 153 |
+
return ArticleLoadResult(
|
| 154 |
+
articles=tuple(article for _, article in valid_candidates),
|
| 155 |
+
errors=tuple(errors),
|
| 156 |
+
source_count=len(raw_records),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def load_articles(path: str | Path) -> ArticleLoadResult:
|
| 161 |
+
"""Read and validate article records from a JSON file."""
|
| 162 |
+
return validate_article_records(read_article_json(path))
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _safe_doi(raw_record: dict[str, Any]) -> str | None:
|
| 166 |
+
value = raw_record.get("DOI")
|
| 167 |
+
return value if isinstance(value, str) else None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _issues_from_pydantic_error(
|
| 171 |
+
exc: ValidationError, *, raw_record: dict[str, Any], index: int
|
| 172 |
+
) -> tuple[ArticleValidationIssue, ...]:
|
| 173 |
+
issues: list[ArticleValidationIssue] = []
|
| 174 |
+
for error in exc.errors():
|
| 175 |
+
location = error.get("loc", ())
|
| 176 |
+
field = str(location[0]) if location else None
|
| 177 |
+
issues.append(
|
| 178 |
+
ArticleValidationIssue(
|
| 179 |
+
index=index,
|
| 180 |
+
code="MODEL_VALIDATION_ERROR",
|
| 181 |
+
field=field,
|
| 182 |
+
message=str(error.get("msg", "Article model validation failed.")),
|
| 183 |
+
DOI=_safe_doi(raw_record),
|
| 184 |
+
)
|
| 185 |
+
)
|
| 186 |
+
return tuple(issues)
|
src/gcmd_classifier/errors.py
CHANGED
|
@@ -27,3 +27,19 @@ class InvalidHierarchyTransitionError(VocabularyError):
|
|
| 27 |
|
| 28 |
class VocabularyLookupError(VocabularyError):
|
| 29 |
"""Raised when a requested vocabulary concept or relationship is missing."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
class VocabularyLookupError(VocabularyError):
|
| 29 |
"""Raised when a requested vocabulary concept or relationship is missing."""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ArticleError(GCMDClassifierError):
|
| 33 |
+
"""Base exception for article loading and validation errors."""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ArticleFileNotFoundError(ArticleError):
|
| 37 |
+
"""Raised when an article source file does not exist."""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ArticleJSONDecodeError(ArticleError):
|
| 41 |
+
"""Raised when an article source file contains invalid JSON."""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ArticleTopLevelTypeError(ArticleError):
|
| 45 |
+
"""Raised when an article source file is not a top-level JSON list."""
|
src/gcmd_classifier/models.py
CHANGED
|
@@ -40,3 +40,46 @@ class CanonicalConceptRecord(BaseModel):
|
|
| 40 |
assignable: bool
|
| 41 |
definition: str | None = None
|
| 42 |
vocabulary_version: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
assignable: bool
|
| 41 |
definition: str | None = None
|
| 42 |
vocabulary_version: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ArticleRecord(BaseModel):
|
| 46 |
+
"""Validated source article record with exact serialized field names."""
|
| 47 |
+
|
| 48 |
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
| 49 |
+
|
| 50 |
+
DOI: str
|
| 51 |
+
Title: str
|
| 52 |
+
Year: int
|
| 53 |
+
Abstract: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ArticleValidationIssue(BaseModel):
|
| 57 |
+
"""Structured validation issue for one article source record."""
|
| 58 |
+
|
| 59 |
+
model_config = ConfigDict(frozen=True)
|
| 60 |
+
|
| 61 |
+
index: int | None
|
| 62 |
+
code: str
|
| 63 |
+
message: str
|
| 64 |
+
field: str | None = None
|
| 65 |
+
DOI: str | None = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class ArticleLoadResult(BaseModel):
|
| 69 |
+
"""Aggregate article loading result preserving valid records and all issues."""
|
| 70 |
+
|
| 71 |
+
model_config = ConfigDict(frozen=True)
|
| 72 |
+
|
| 73 |
+
articles: tuple[ArticleRecord, ...]
|
| 74 |
+
errors: tuple[ArticleValidationIssue, ...] = Field(default_factory=tuple)
|
| 75 |
+
source_count: int
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def valid_count(self) -> int:
|
| 79 |
+
"""Number of records that passed validation and duplicate checks."""
|
| 80 |
+
return len(self.articles)
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def has_errors(self) -> bool:
|
| 84 |
+
"""Whether any source records failed validation."""
|
| 85 |
+
return bool(self.errors)
|
tests/fixtures/articles_invalid.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"Title": "Missing DOI",
|
| 4 |
+
"Year": 2024,
|
| 5 |
+
"Abstract": "A record without DOI."
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"DOI": "10.1000/MISSINGTITLE",
|
| 9 |
+
"Year": 2024,
|
| 10 |
+
"Abstract": "A record without title."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"DOI": "10.1000/MISSINGYEAR",
|
| 14 |
+
"Title": "Missing year",
|
| 15 |
+
"Abstract": "A record without year."
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"DOI": "10.1000/MISSINGABSTRACT",
|
| 19 |
+
"Title": "Missing abstract",
|
| 20 |
+
"Year": 2024
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"DOI": "",
|
| 24 |
+
"Title": "Empty DOI",
|
| 25 |
+
"Year": 2024,
|
| 26 |
+
"Abstract": "Empty DOI should fail."
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"DOI": "10.1000/EMPTYTITLE",
|
| 30 |
+
"Title": "",
|
| 31 |
+
"Year": 2024,
|
| 32 |
+
"Abstract": "Empty title should fail."
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"DOI": "10.1000/WHITESPACE",
|
| 36 |
+
"Title": " ",
|
| 37 |
+
"Year": 2024,
|
| 38 |
+
"Abstract": "Whitespace title is preserved and currently valid."
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"DOI": 123,
|
| 42 |
+
"Title": "Non-string DOI",
|
| 43 |
+
"Year": 2024,
|
| 44 |
+
"Abstract": "DOI must be a string."
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"DOI": "10.1000/NONSTRINGTITLE",
|
| 48 |
+
"Title": ["bad"],
|
| 49 |
+
"Year": 2024,
|
| 50 |
+
"Abstract": "Title must be a string."
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"DOI": "10.1000/NONSTRINGABSTRACT",
|
| 54 |
+
"Title": "Non-string abstract",
|
| 55 |
+
"Year": 2024,
|
| 56 |
+
"Abstract": 42
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"DOI": "10.1000/STRINGYEAR",
|
| 60 |
+
"Title": "String year",
|
| 61 |
+
"Year": "2024",
|
| 62 |
+
"Abstract": "Year must not be a string."
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"DOI": "10.1000/FLOATYEAR",
|
| 66 |
+
"Title": "Float year",
|
| 67 |
+
"Year": 2024.0,
|
| 68 |
+
"Abstract": "Year must not be a float."
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"DOI": "10.1000/BOOLYEAR",
|
| 72 |
+
"Title": "Boolean year",
|
| 73 |
+
"Year": true,
|
| 74 |
+
"Abstract": "Year must not be Boolean."
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"DOI": null,
|
| 78 |
+
"Title": "Null DOI",
|
| 79 |
+
"Year": 2024,
|
| 80 |
+
"Abstract": "Null DOI should fail."
|
| 81 |
+
},
|
| 82 |
+
"not an object",
|
| 83 |
+
{
|
| 84 |
+
"DOI": "10.1000/DUPLICATE",
|
| 85 |
+
"Title": "Duplicate one",
|
| 86 |
+
"Year": 2024,
|
| 87 |
+
"Abstract": "First duplicate."
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"DOI": "10.1000/DUPLICATE",
|
| 91 |
+
"Title": "Duplicate two",
|
| 92 |
+
"Year": 2025,
|
| 93 |
+
"Abstract": "Second duplicate."
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"DOI": "10.1000/EXTRA",
|
| 97 |
+
"Title": "Unexpected field",
|
| 98 |
+
"Year": 2025,
|
| 99 |
+
"Abstract": "Extra fields are rejected for MVP.",
|
| 100 |
+
"Journal": "Example"
|
| 101 |
+
}
|
| 102 |
+
]
|
tests/fixtures/articles_valid.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"DOI": "10.0001/ATMOS.TEST",
|
| 4 |
+
"Title": " High-Resolution Rainfall Trends over Mountain Basins: A 20-Year Study ",
|
| 5 |
+
"Year": 2024,
|
| 6 |
+
"Abstract": "Precipitation intensity, snowpack, and runoff timing are evaluated using station and satellite observations."
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"DOI": "10.0002/MULTI.TEST",
|
| 10 |
+
"Title": "Atmosphere-ocean coupling during coastal marine heatwaves",
|
| 11 |
+
"Year": 2025,
|
| 12 |
+
"Abstract": "The study links sea surface temperature anomalies, boundary-layer humidity, and plankton bloom timing."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"DOI": "10.0003/TITLEONLY.TEST",
|
| 16 |
+
"Title": "Editorial: New instrumentation for environmental archives?",
|
| 17 |
+
"Year": 2023,
|
| 18 |
+
"Abstract": ""
|
| 19 |
+
}
|
| 20 |
+
]
|
tests/test_articles.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from gcmd_classifier.articles import (
|
| 9 |
+
load_articles,
|
| 10 |
+
read_article_json,
|
| 11 |
+
validate_article_record,
|
| 12 |
+
validate_article_records,
|
| 13 |
+
)
|
| 14 |
+
from gcmd_classifier.errors import (
|
| 15 |
+
ArticleFileNotFoundError,
|
| 16 |
+
ArticleJSONDecodeError,
|
| 17 |
+
ArticleTopLevelTypeError,
|
| 18 |
+
)
|
| 19 |
+
from gcmd_classifier.models import ArticleRecord
|
| 20 |
+
|
| 21 |
+
VALID_FIXTURE = Path("tests/fixtures/articles_valid.json")
|
| 22 |
+
INVALID_FIXTURE = Path("tests/fixtures/articles_invalid.json")
|
| 23 |
+
FULL_ARTICLES_PATH = Path("data/articles.json")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_valid_article_loading() -> None:
|
| 27 |
+
result = load_articles(VALID_FIXTURE)
|
| 28 |
+
|
| 29 |
+
assert result.source_count == 3
|
| 30 |
+
assert result.valid_count == 3
|
| 31 |
+
assert result.errors == ()
|
| 32 |
+
assert all(isinstance(article, ArticleRecord) for article in result.articles)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_preserves_source_order() -> None:
|
| 36 |
+
result = load_articles(VALID_FIXTURE)
|
| 37 |
+
|
| 38 |
+
assert [article.DOI for article in result.articles] == [
|
| 39 |
+
"10.0001/ATMOS.TEST",
|
| 40 |
+
"10.0002/MULTI.TEST",
|
| 41 |
+
"10.0003/TITLEONLY.TEST",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_exact_source_value_preservation() -> None:
|
| 46 |
+
raw = json.loads(VALID_FIXTURE.read_text())
|
| 47 |
+
result = load_articles(VALID_FIXTURE)
|
| 48 |
+
first = result.articles[0]
|
| 49 |
+
|
| 50 |
+
assert first.DOI == raw[0]["DOI"]
|
| 51 |
+
assert first.Title == raw[0]["Title"]
|
| 52 |
+
assert first.Year == raw[0]["Year"]
|
| 53 |
+
assert first.Abstract == raw[0]["Abstract"]
|
| 54 |
+
assert first.Title.startswith(" ")
|
| 55 |
+
assert first.Title.endswith(" ")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_empty_abstract_is_valid_and_preserved() -> None:
|
| 59 |
+
result = load_articles(VALID_FIXTURE)
|
| 60 |
+
article = result.articles[2]
|
| 61 |
+
|
| 62 |
+
assert article.Abstract == ""
|
| 63 |
+
assert article.Title == "Editorial: New instrumentation for environmental archives?"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_exact_serialization_field_names() -> None:
|
| 67 |
+
article = load_articles(VALID_FIXTURE).articles[0]
|
| 68 |
+
|
| 69 |
+
assert article.model_dump() == {
|
| 70 |
+
"DOI": "10.0001/ATMOS.TEST",
|
| 71 |
+
"Title": " High-Resolution Rainfall Trends over Mountain Basins: A 20-Year Study ",
|
| 72 |
+
"Year": 2024,
|
| 73 |
+
"Abstract": "Precipitation intensity, snowpack, and runoff timing are evaluated using "
|
| 74 |
+
"station and satellite observations.",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_invalid_fixture_reports_multiple_record_errors() -> None:
|
| 79 |
+
result = load_articles(INVALID_FIXTURE)
|
| 80 |
+
codes = [error.code for error in result.errors]
|
| 81 |
+
|
| 82 |
+
assert result.source_count == 18
|
| 83 |
+
assert result.has_errors is True
|
| 84 |
+
assert "MISSING_REQUIRED_FIELD" in codes
|
| 85 |
+
assert "EMPTY_REQUIRED_STRING" in codes
|
| 86 |
+
assert "INVALID_FIELD_TYPE" in codes
|
| 87 |
+
assert "ARTICLE_NOT_OBJECT" in codes
|
| 88 |
+
assert "DUPLICATE_DOI" in codes
|
| 89 |
+
assert "UNEXPECTED_FIELD" in codes
|
| 90 |
+
assert len(result.errors) >= 17
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.parametrize(
|
| 94 |
+
("record", "field"),
|
| 95 |
+
[
|
| 96 |
+
({"Title": "T", "Year": 2024, "Abstract": "A"}, "DOI"),
|
| 97 |
+
({"DOI": "10.x", "Year": 2024, "Abstract": "A"}, "Title"),
|
| 98 |
+
({"DOI": "10.x", "Title": "T", "Abstract": "A"}, "Year"),
|
| 99 |
+
({"DOI": "10.x", "Title": "T", "Year": 2024}, "Abstract"),
|
| 100 |
+
],
|
| 101 |
+
)
|
| 102 |
+
def test_missing_required_fields(record: dict, field: str) -> None:
|
| 103 |
+
article, errors = validate_article_record(record, index=7)
|
| 104 |
+
|
| 105 |
+
assert article is None
|
| 106 |
+
assert any(error.code == "MISSING_REQUIRED_FIELD" and error.field == field for error in errors)
|
| 107 |
+
assert all(error.index == 7 for error in errors)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.parametrize(
|
| 111 |
+
("record", "field"),
|
| 112 |
+
[
|
| 113 |
+
({"DOI": "", "Title": "T", "Year": 2024, "Abstract": "A"}, "DOI"),
|
| 114 |
+
({"DOI": "10.x", "Title": "", "Year": 2024, "Abstract": "A"}, "Title"),
|
| 115 |
+
],
|
| 116 |
+
)
|
| 117 |
+
def test_empty_required_strings(record: dict, field: str) -> None:
|
| 118 |
+
article, errors = validate_article_record(record, index=1)
|
| 119 |
+
|
| 120 |
+
assert article is None
|
| 121 |
+
assert any(error.code == "EMPTY_REQUIRED_STRING" and error.field == field for error in errors)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_whitespace_only_title_is_preserved_not_normalized() -> None:
|
| 125 |
+
record = {"DOI": "10.x/space", "Title": " ", "Year": 2024, "Abstract": "A"}
|
| 126 |
+
|
| 127 |
+
article, errors = validate_article_record(record, index=0)
|
| 128 |
+
|
| 129 |
+
assert errors == ()
|
| 130 |
+
assert article is not None
|
| 131 |
+
assert article.Title == " "
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@pytest.mark.parametrize(
|
| 135 |
+
("field", "value"),
|
| 136 |
+
[
|
| 137 |
+
("DOI", 123),
|
| 138 |
+
("Title", ["bad"]),
|
| 139 |
+
("Abstract", 42),
|
| 140 |
+
],
|
| 141 |
+
)
|
| 142 |
+
def test_non_string_fields_rejected(field: str, value: object) -> None:
|
| 143 |
+
record = {"DOI": "10.x", "Title": "T", "Year": 2024, "Abstract": "A"}
|
| 144 |
+
record[field] = value
|
| 145 |
+
|
| 146 |
+
article, errors = validate_article_record(record, index=2)
|
| 147 |
+
|
| 148 |
+
assert article is None
|
| 149 |
+
assert any(error.code == "INVALID_FIELD_TYPE" and error.field == field for error in errors)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@pytest.mark.parametrize("year", ["2024", 2024.0, True, False])
|
| 153 |
+
def test_invalid_year_types_rejected(year: object) -> None:
|
| 154 |
+
record = {"DOI": "10.x/year", "Title": "T", "Year": year, "Abstract": "A"}
|
| 155 |
+
|
| 156 |
+
article, errors = validate_article_record(record, index=3)
|
| 157 |
+
|
| 158 |
+
assert article is None
|
| 159 |
+
assert any(error.code == "INVALID_FIELD_TYPE" and error.field == "Year" for error in errors)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_null_field_values_rejected() -> None:
|
| 163 |
+
record = {"DOI": None, "Title": None, "Year": None, "Abstract": None}
|
| 164 |
+
|
| 165 |
+
article, errors = validate_article_record(record, index=4)
|
| 166 |
+
|
| 167 |
+
assert article is None
|
| 168 |
+
assert len([error for error in errors if error.code == "INVALID_FIELD_TYPE"]) == 4
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_duplicate_doi_detection_excludes_duplicate_records_from_valid_records() -> None:
|
| 172 |
+
raw_records = [
|
| 173 |
+
{"DOI": "10.same", "Title": "One", "Year": 2024, "Abstract": "A"},
|
| 174 |
+
{"DOI": "10.same", "Title": "Two", "Year": 2025, "Abstract": "B"},
|
| 175 |
+
{"DOI": "10.unique", "Title": "Three", "Year": 2026, "Abstract": "C"},
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
+
result = validate_article_records(raw_records)
|
| 179 |
+
|
| 180 |
+
assert [article.DOI for article in result.articles] == ["10.unique"]
|
| 181 |
+
assert [error.index for error in result.errors if error.code == "DUPLICATE_DOI"] == [0, 1]
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_non_object_article_entry() -> None:
|
| 185 |
+
result = validate_article_records(["bad"])
|
| 186 |
+
|
| 187 |
+
assert result.articles == ()
|
| 188 |
+
assert result.errors[0].code == "ARTICLE_NOT_OBJECT"
|
| 189 |
+
assert result.errors[0].index == 0
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_top_level_non_list_json() -> None:
|
| 193 |
+
with pytest.raises(ArticleTopLevelTypeError):
|
| 194 |
+
validate_article_records({"DOI": "10.x"})
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_invalid_json(tmp_path: Path) -> None:
|
| 198 |
+
path = tmp_path / "invalid.json"
|
| 199 |
+
path.write_text("[{bad json]")
|
| 200 |
+
|
| 201 |
+
with pytest.raises(ArticleJSONDecodeError):
|
| 202 |
+
read_article_json(path)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_file_not_found_behavior(tmp_path: Path) -> None:
|
| 206 |
+
with pytest.raises(ArticleFileNotFoundError):
|
| 207 |
+
load_articles(tmp_path / "missing.json")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_source_file_immutability() -> None:
|
| 211 |
+
before = FULL_ARTICLES_PATH.read_bytes()
|
| 212 |
+
load_articles(FULL_ARTICLES_PATH)
|
| 213 |
+
after = FULL_ARTICLES_PATH.read_bytes()
|
| 214 |
+
|
| 215 |
+
assert after == before
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_full_data_smoke_current_articles_file() -> None:
|
| 219 |
+
raw_records = json.loads(FULL_ARTICLES_PATH.read_text())
|
| 220 |
+
result = load_articles(FULL_ARTICLES_PATH)
|
| 221 |
+
|
| 222 |
+
assert result.source_count == 468
|
| 223 |
+
assert len(raw_records) == 468
|
| 224 |
+
assert result.valid_count == 467
|
| 225 |
+
assert len({article.DOI for article in result.articles}) == 467
|
| 226 |
+
assert result.has_errors is True
|
| 227 |
+
assert len(result.errors) == 1
|
| 228 |
+
error = result.errors[0]
|
| 229 |
+
assert error.index == 467
|
| 230 |
+
assert error.field == "DOI"
|
| 231 |
+
assert error.code == "EMPTY_REQUIRED_STRING"
|
| 232 |
+
|
| 233 |
+
empty_abstract_indices = [
|
| 234 |
+
index for index, record in enumerate(raw_records) if record.get("Abstract") == ""
|
| 235 |
+
]
|
| 236 |
+
assert len(empty_abstract_indices) == 14
|
| 237 |
+
assert all(
|
| 238 |
+
raw_records[index]["DOI"] in {article.DOI for article in result.articles}
|
| 239 |
+
for index in empty_abstract_indices
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
valid_raw_records = [record for index, record in enumerate(raw_records) if index != 467]
|
| 243 |
+
assert [article.DOI for article in result.articles] == [
|
| 244 |
+
record["DOI"] for record in valid_raw_records
|
| 245 |
+
]
|
| 246 |
+
assert result.articles[0].model_dump() == valid_raw_records[0]
|