| from __future__ import annotations |
|
|
| import json |
| import re |
| from pathlib import Path |
|
|
| import pytest |
| from jsonschema import Draft202012Validator |
| from pydantic import ValidationError |
|
|
| from gcmd_classifier.models import ( |
| ArticleClassificationOutcome, |
| ArticleProcessingStatus, |
| ArticleResult, |
| ClassificationFinalStatus, |
| ClassificationRecord, |
| ConfidenceMetadata, |
| DeterministicValidationResult, |
| OutputError, |
| ProcessingMetadata, |
| ReviewStatus, |
| RunSummary, |
| SupportType, |
| ) |
|
|
| CLASSIFICATION_SCHEMA_PATH = Path("schemas/classification_result.schema.json") |
| RUN_SUMMARY_SCHEMA_PATH = Path("schemas/run_summary.schema.json") |
|
|
|
|
| def _valid_validation() -> DeterministicValidationResult: |
| return DeterministicValidationResult(valid=True) |
|
|
|
|
| def _invalid_validation() -> DeterministicValidationResult: |
| return DeterministicValidationResult( |
| valid=False, |
| errors=(OutputError(code="INVALID_UUID", message="UUID was not found."),), |
| ) |
|
|
|
|
| def _classification(**overrides: object) -> ClassificationRecord: |
| values: dict[str, object] = { |
| "UUID": "03ddc432-906d-4469-bb00-179c828dbea4", |
| "name": "CARBON DIOXIDE PROFILES", |
| "level": "Variable_Level_3", |
| "canonical_path": "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON DIOXIDE PROFILES", |
| "path_components": [ |
| "ATMOSPHERE", |
| "ATMOSPHERIC CHEMISTRY", |
| "CARBON DIOXIDE PROFILES", |
| ], |
| "topic": "ATMOSPHERE", |
| "term": "ATMOSPHERIC CHEMISTRY", |
| "parent_uuid": "parent-uuid", |
| "branch_id": "branch-1", |
| "confidence": {"topic": 0.9, "term": 0.8, "final": 0.85}, |
| "classifier_evidence": ( |
| "The article explicitly describes atmospheric carbon dioxide profiles." |
| ), |
| "support_type": "explicit", |
| "reason_for_stopping": "The selected concept is the deepest supported child.", |
| "deterministic_validation": _valid_validation(), |
| "final_status": "accepted", |
| "review_required": False, |
| "review_status": "not_required", |
| } |
| values.update(overrides) |
| return ClassificationRecord.model_validate(values) |
|
|
|
|
| def _classified_article(**overrides: object) -> ArticleResult: |
| values: dict[str, object] = { |
| "DOI": "10.example/article", |
| "Title": "Vertical distribution of atmospheric carbon dioxide", |
| "Year": 2025, |
| "Abstract": "Profiles of atmospheric carbon dioxide are evaluated.", |
| "processing_status": "completed", |
| "classification_outcome": "classified", |
| "classifications": [_classification()], |
| "review_status": "not_required", |
| "processing_metadata": { |
| "application_version": "0.1.0", |
| "vocabulary_hash": "abc123", |
| "model_provider": "fake", |
| "model_name": "fake-model", |
| "prompt_versions": {"topic_router": "1.0"}, |
| "cache_used": False, |
| "title_available": True, |
| "abstract_available": True, |
| }, |
| } |
| values.update(overrides) |
| return ArticleResult.model_validate(values) |
|
|
|
|
| def test_valid_accepted_classification_record() -> None: |
| record = _classification() |
|
|
| assert record.final_status is ClassificationFinalStatus.ACCEPTED |
| assert record.deterministic_validation.valid is True |
| assert record.confidence is not None |
| assert record.confidence.final == 0.85 |
|
|
|
|
| def test_valid_reduced_to_ancestor_classification_record() -> None: |
| record = _classification( |
| UUID="b9c56939-c624-467d-b196-e56a5b660334", |
| name="ATMOSPHERIC CHEMISTRY", |
| level="Term", |
| canonical_path="ATMOSPHERE > ATMOSPHERIC CHEMISTRY", |
| path_components=["ATMOSPHERE", "ATMOSPHERIC CHEMISTRY"], |
| final_status="reduced_to_ancestor", |
| original_candidate={ |
| "UUID": "child-uuid", |
| "canonical_path": "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CHILD", |
| }, |
| ) |
|
|
| assert record.final_status is ClassificationFinalStatus.REDUCED_TO_ANCESTOR |
| assert record.original_candidate is not None |
|
|
|
|
| def test_valid_rejected_classification_record() -> None: |
| record = _classification( |
| deterministic_validation=_invalid_validation(), |
| final_status="rejected", |
| errors=(OutputError(code="INVALID_UUID", message="UUID was not found."),), |
| ) |
|
|
| assert record.final_status is ClassificationFinalStatus.REJECTED |
| assert record.deterministic_validation.valid is False |
|
|
|
|
| def test_accepted_classification_requires_valid_deterministic_validation() -> None: |
| with pytest.raises(ValidationError): |
| _classification(deterministic_validation=_invalid_validation()) |
|
|
|
|
| def test_valid_article_result_with_classifications() -> None: |
| result = _classified_article() |
|
|
| assert result.processing_status is ArticleProcessingStatus.COMPLETED |
| assert result.classification_outcome is ArticleClassificationOutcome.CLASSIFIED |
| assert len(result.classifications) == 1 |
|
|
|
|
| def test_valid_minimal_no_classification_article_result() -> None: |
| result = ArticleResult.model_validate( |
| { |
| "DOI": "10.example/no-classification", |
| "Title": "Editorial note", |
| "Year": 2025, |
| "Abstract": "", |
| "processing_status": "completed", |
| "classification_outcome": "not_classified", |
| "classifications": [], |
| "no_classification_reason": "No defensible GCMD concept was supported.", |
| "review_status": "not_required", |
| } |
| ) |
|
|
| assert result.Abstract == "" |
| assert result.review_status is ReviewStatus.NOT_REQUIRED |
|
|
|
|
| def test_valid_failed_article_result_without_classifications() -> None: |
| result = ArticleResult.model_validate( |
| { |
| "DOI": "10.example/failed", |
| "Title": "A failed article", |
| "Year": 2025, |
| "Abstract": "Text.", |
| "processing_status": "failed", |
| "classification_outcome": None, |
| "classifications": [], |
| "errors": [OutputError(code="MODEL_TIMEOUT", message="Model timed out.")], |
| } |
| ) |
|
|
| assert result.processing_status is ArticleProcessingStatus.FAILED |
| assert result.classifications == () |
|
|
|
|
| def test_valid_partial_article_result_without_classifications() -> None: |
| result = ArticleResult.model_validate( |
| { |
| "DOI": "10.example/partial", |
| "Title": "A partially processed article", |
| "Year": 2025, |
| "Abstract": "Text.", |
| "processing_status": "partial", |
| "classification_outcome": None, |
| "classifications": [], |
| "warnings": [{"code": "BRANCH_FAILED", "message": "One branch failed."}], |
| } |
| ) |
|
|
| assert result.processing_status is ArticleProcessingStatus.PARTIAL |
| assert result.classification_outcome is None |
|
|
|
|
| def test_status_scopes_do_not_accept_values_from_wrong_enum() -> None: |
| with pytest.raises(ValidationError): |
| ArticleResult.model_validate( |
| { |
| "DOI": "10.example/wrong-status", |
| "Title": "Wrong status", |
| "Year": 2025, |
| "Abstract": "Text.", |
| "processing_status": "not_classified", |
| "classification_outcome": "not_classified", |
| "classifications": [], |
| "no_classification_reason": "No classification.", |
| } |
| ) |
|
|
| with pytest.raises(ValidationError): |
| _classification(final_status="not_classified") |
|
|
|
|
| def test_generated_fields_use_snake_case_and_source_fields_remain_exact() -> None: |
| dumped = _classified_article().model_dump(mode="json") |
|
|
| assert {"DOI", "Title", "Year", "Abstract"}.issubset(dumped) |
| generated_fields = set(dumped) - {"DOI", "Title", "Year", "Abstract"} |
| assert generated_fields |
| assert all(re.fullmatch(r"[a-z][a-z0-9_]*", field) for field in generated_fields) |
| assert "processing_status" in dumped |
| assert "classification_outcome" in dumped |
|
|
|
|
| def test_empty_abstract_is_allowed_in_article_result_output() -> None: |
| result = _classified_article(Abstract="", processing_metadata={"abstract_available": False}) |
|
|
| assert result.Abstract == "" |
| assert result.processing_metadata.abstract_available is False |
|
|
|
|
| def test_boolean_year_is_invalid_for_article_result() -> None: |
| with pytest.raises(ValidationError): |
| _classified_article(Year=True) |
|
|
|
|
| def test_review_compatible_values_can_be_represented_without_review_trigger_logic() -> None: |
| review_record = _classification(final_status="review_required", review_required=True) |
| result = _classified_article( |
| classification_outcome="pending_review", |
| classifications=[review_record], |
| review_status="pending", |
| ) |
|
|
| assert result.classification_outcome is ArticleClassificationOutcome.PENDING_REVIEW |
| assert result.classifications[0].final_status is ClassificationFinalStatus.REVIEW_REQUIRED |
| assert result.review_status is ReviewStatus.PENDING |
|
|
|
|
| def test_confidence_metadata_range_validation() -> None: |
| assert ConfidenceMetadata(final=1.0).final == 1.0 |
| with pytest.raises(ValidationError): |
| ConfidenceMetadata(final=1.01) |
|
|
|
|
| def test_schema_files_are_valid_json() -> None: |
| for path in (CLASSIFICATION_SCHEMA_PATH, RUN_SUMMARY_SCHEMA_PATH): |
| schema = json.loads(path.read_text()) |
| Draft202012Validator.check_schema(schema) |
|
|
|
|
| def test_minimal_no_classification_result_validates_against_json_schema() -> None: |
| schema = json.loads(CLASSIFICATION_SCHEMA_PATH.read_text()) |
| result = ArticleResult.model_validate( |
| { |
| "DOI": "10.example/no-classification", |
| "Title": "Editorial note", |
| "Year": 2025, |
| "Abstract": "", |
| "processing_status": "completed", |
| "classification_outcome": "not_classified", |
| "classifications": [], |
| "no_classification_reason": "No defensible GCMD concept was supported.", |
| "review_status": "not_required", |
| } |
| ) |
|
|
| Draft202012Validator(schema).validate(result.model_dump(mode="json")) |
|
|
|
|
| def test_classified_result_validates_against_json_schema() -> None: |
| schema = json.loads(CLASSIFICATION_SCHEMA_PATH.read_text()) |
|
|
| Draft202012Validator(schema).validate(_classified_article().model_dump(mode="json")) |
|
|
|
|
| def test_failed_result_validates_against_json_schema() -> None: |
| schema = json.loads(CLASSIFICATION_SCHEMA_PATH.read_text()) |
| result = ArticleResult.model_validate( |
| { |
| "DOI": "10.example/failed", |
| "Title": "Failed article", |
| "Year": 2025, |
| "Abstract": "Text.", |
| "processing_status": "failed", |
| "classification_outcome": None, |
| "classifications": [], |
| "errors": [{"code": "LOAD_FAILED", "message": "Article failed."}], |
| } |
| ) |
|
|
| Draft202012Validator(schema).validate(result.model_dump(mode="json")) |
|
|
|
|
| def test_basic_run_summary_validates_against_json_schema() -> None: |
| schema = json.loads(RUN_SUMMARY_SCHEMA_PATH.read_text()) |
| summary = RunSummary( |
| run_id="run-1", |
| articles_received=3, |
| articles_completed=2, |
| articles_failed=1, |
| accepted_classifications=2, |
| ) |
|
|
| Draft202012Validator(schema).validate(summary.model_dump(mode="json")) |
|
|
|
|
| def test_processing_metadata_can_represent_title_only_input() -> None: |
| metadata = ProcessingMetadata(title_available=True, abstract_available=False) |
|
|
| assert metadata.title_available is True |
| assert metadata.abstract_available is False |
|
|
|
|
| def test_support_type_enum_values_are_schema_compatible() -> None: |
| record = _classification(support_type=SupportType.MIXED) |
|
|
| assert record.support_type is SupportType.MIXED |
|
|