GCMD_Keyword_Classifier_MVP / tests /test_output_schema.py
igerasimov's picture
MVP Milestone 4
0539596
Raw
History Blame Contribute Delete
11.8 kB
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