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