| from __future__ import annotations |
|
|
| import json |
| import logging |
| from pathlib import Path |
|
|
| from jsonschema import Draft202012Validator |
|
|
| from gcmd_classifier.articles import validate_article_records |
| from gcmd_classifier.config import ModelSettings |
| from gcmd_classifier.llm import FakeModelClient |
| from gcmd_classifier.logging_config import sanitize_log_details |
| from gcmd_classifier.models import ( |
| ArticleClassificationOutcome, |
| ArticleProcessingStatus, |
| ArticleRecord, |
| ) |
| from gcmd_classifier.persistence import ArticleResultCache, JsonResultStore |
| from gcmd_classifier.pipeline import classify_article, run_batch |
| from gcmd_classifier.vocabulary import load_vocabulary |
|
|
| FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json") |
| FULL_ARTICLES_PATH = Path("data/articles.json") |
| FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json") |
| RUN_SUMMARY_SCHEMA_PATH = Path("schemas/run_summary.schema.json") |
|
|
|
|
| def _index(): |
| return load_vocabulary(FIXTURE_PATH) |
|
|
|
|
| def _article(abstract: str = "Atmospheric carbon dioxide profiles are discussed.") -> ArticleRecord: |
| return ArticleRecord( |
| DOI="10.example/pipeline", |
| Title="Atmospheric carbon dioxide observations", |
| Year=2025, |
| Abstract=abstract, |
| ) |
|
|
|
|
| def _decision( |
| candidate_id: str, |
| *, |
| support_type: str = "explicit", |
| confidence: float = 0.8, |
| ) -> dict: |
| return { |
| "candidate_id": candidate_id, |
| "confidence": confidence, |
| "evidence": f"Evidence for {candidate_id}.", |
| "support_type": support_type, |
| "reason": f"Reason for {candidate_id}.", |
| } |
|
|
|
|
| def _no_topic(reason: str = "No supported Topic.") -> dict: |
| return {"selected": [], "no_selection_reason": reason} |
|
|
|
|
| def _select_topic(candidate_id: str = "topic_0001") -> dict: |
| return {"selected": [_decision(candidate_id)]} |
|
|
|
|
| def _select_term(candidate_id: str = "term_0001") -> dict: |
| return {"selected": [_decision(candidate_id)], "stop_at_parent": False} |
|
|
|
|
| def _select_variable(*candidate_ids: str) -> dict: |
| return { |
| "selected": [_decision(candidate_id) for candidate_id in candidate_ids], |
| "stop_at_parent": False, |
| } |
|
|
|
|
| def _stop(reason: str) -> dict: |
| return {"selected": [], "stop_at_parent": True, "stop_reason": reason} |
|
|
|
|
| def test_single_article_deep_variable_classification_is_accepted() -> None: |
| client = FakeModelClient( |
| [ |
| _select_topic(), |
| _select_term(), |
| _select_variable("variable_0001"), |
| _select_variable("variable_0001"), |
| _select_variable("variable_0001"), |
| ] |
| ) |
|
|
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.processing_status is ArticleProcessingStatus.COMPLETED |
| assert result.classification_outcome is ArticleClassificationOutcome.CLASSIFIED |
| assert [record.UUID for record in result.classifications] == ["vl3-carbon-dioxide-profiles"] |
| assert result.classifications[0].deterministic_validation.valid is True |
| assert result.processing_metadata.model_calls == 5 |
|
|
|
|
| def test_article_stops_at_topic_and_becomes_topic_classification() -> None: |
| client = FakeModelClient([_select_topic(), _stop("Topic is the deepest supported level.")]) |
|
|
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.classifications[0].UUID == "topic-atmosphere" |
| assert result.classifications[0].level == "Topic" |
| assert result.classifications[0].reason_for_stopping == "Topic is the deepest supported level." |
|
|
|
|
| def test_review_risk_flagging_preserves_accepted_topic_classification() -> None: |
| client = FakeModelClient( |
| [ |
| {"selected": [_decision("topic_0001", support_type="inferred", confidence=0.9)]}, |
| _stop("Topic is the deepest supported level."), |
| ] |
| ) |
|
|
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| ) |
|
|
| classification = result.classifications[0] |
| assert classification.UUID == "topic-atmosphere" |
| assert classification.level == "Topic" |
| assert classification.final_status == "accepted" |
| assert classification.review_required is True |
| assert classification.warnings[-1].code == "REVIEW_RECOMMENDED_WEAK_SUPPORT" |
|
|
|
|
| def test_article_stops_at_term_and_becomes_term_classification() -> None: |
| client = FakeModelClient( |
| [_select_topic(), _select_term(), _stop("Term is the deepest supported level.")] |
| ) |
|
|
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.classifications[0].UUID == "term-atmospheric-chemistry" |
| assert result.classifications[0].level == "Term" |
| assert result.classifications[0].reason_for_stopping == "Term is the deepest supported level." |
|
|
|
|
| def test_no_topic_selection_is_completed_not_classified() -> None: |
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic("Not Earth science.")]), |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.processing_status is ArticleProcessingStatus.COMPLETED |
| assert result.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED |
| assert result.classifications == () |
| assert result.no_classification_reason == "Not Earth science." |
|
|
|
|
| def test_invalid_model_candidate_produces_structured_error() -> None: |
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=FakeModelClient([_select_topic("missing_topic")]), |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.processing_status is ArticleProcessingStatus.FAILED |
| assert result.classifications == () |
| assert result.errors[0].stage == "topic_routing" |
| assert result.errors[0].code == "UnknownCandidateIDError" |
|
|
|
|
| def test_partial_branch_failure_preserves_successful_sibling_outcome() -> None: |
| client = FakeModelClient( |
| [ |
| _select_topic(), |
| _select_term(), |
| _select_variable("variable_0001"), |
| _select_variable("variable_0001", "variable_0002"), |
| _select_variable("missing_variable"), |
| ] |
| ) |
|
|
| result = classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.processing_status is ArticleProcessingStatus.PARTIAL |
| assert result.classification_outcome is ArticleClassificationOutcome.CLASSIFIED |
| assert [record.UUID for record in result.classifications] == ["vl2-methane"] |
| assert result.errors |
| assert result.errors[0].stage == "variable_descent" |
|
|
|
|
| def test_empty_abstract_remains_valid_and_can_be_processed() -> None: |
| result = classify_article( |
| article=_article(abstract=""), |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic("Title only is insufficient.")]), |
| settings=ModelSettings(), |
| ) |
|
|
| assert result.Abstract == "" |
| assert result.processing_metadata.abstract_available is False |
| assert result.processing_status is ArticleProcessingStatus.COMPLETED |
|
|
|
|
| def test_batch_processes_multiple_valid_articles_and_preserves_order(tmp_path: Path) -> None: |
| load_result = validate_article_records( |
| [ |
| {"DOI": "10.example/one", "Title": "One", "Year": 2025, "Abstract": ""}, |
| {"DOI": "10.example/two", "Title": "Two", "Year": 2025, "Abstract": ""}, |
| ] |
| ) |
| client = FakeModelClient([_no_topic("No Topic one."), _no_topic("No Topic two.")]) |
|
|
| batch = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| store=JsonResultStore(tmp_path / "results"), |
| ) |
|
|
| assert [result.DOI for result in batch.results] == ["10.example/one", "10.example/two"] |
| assert batch.summary.processed_articles == 2 |
| assert batch.summary.articles_completed == 2 |
| assert batch.summary.articles_not_classified == 2 |
|
|
|
|
| def test_batch_continues_after_one_article_failure(tmp_path: Path) -> None: |
| load_result = validate_article_records( |
| [ |
| {"DOI": "10.example/one", "Title": "One", "Year": 2025, "Abstract": ""}, |
| {"DOI": "10.example/two", "Title": "Two", "Year": 2025, "Abstract": ""}, |
| {"DOI": "10.example/three", "Title": "Three", "Year": 2025, "Abstract": ""}, |
| ] |
| ) |
| client = FakeModelClient([_no_topic(), _select_topic("missing_topic"), _no_topic()]) |
|
|
| batch = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=client, |
| settings=ModelSettings(), |
| store=JsonResultStore(tmp_path / "results"), |
| ) |
|
|
| assert [result.DOI for result in batch.results] == [ |
| "10.example/one", |
| "10.example/two", |
| "10.example/three", |
| ] |
| assert batch.summary.articles_failed == 1 |
| assert batch.summary.articles_completed == 2 |
| assert batch.results[1].errors[0].code == "UnknownCandidateIDError" |
|
|
|
|
| def test_batch_reports_invalid_source_records_without_inventing_doi(tmp_path: Path) -> None: |
| load_result = validate_article_records( |
| [ |
| {"DOI": "10.example/valid", "Title": "Valid", "Year": 2025, "Abstract": ""}, |
| {"DOI": "", "Title": "Invalid", "Year": 2025, "Abstract": ""}, |
| ] |
| ) |
|
|
| batch = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic()]), |
| settings=ModelSettings(), |
| store=JsonResultStore(tmp_path / "results"), |
| ) |
|
|
| assert batch.summary.articles_received == 2 |
| assert batch.summary.valid_article_records == 1 |
| assert batch.summary.invalid_source_records == 1 |
| assert batch.summary.errors[0].DOI == "" |
| assert [result.DOI for result in batch.results] == ["10.example/valid"] |
|
|
|
|
| def test_batch_cache_hit_is_completed_not_skipped_and_force_reprocess_bypasses_cache( |
| tmp_path: Path, |
| ) -> None: |
| load_result = validate_article_records( |
| [{"DOI": "10.example/cache", "Title": "Cache", "Year": 2025, "Abstract": ""}] |
| ) |
| cache = ArticleResultCache(tmp_path / "cache") |
| store = JsonResultStore(tmp_path / "results") |
|
|
| first = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic("First run.")]), |
| settings=ModelSettings(), |
| store=store, |
| cache=cache, |
| ) |
| second = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=FakeModelClient([]), |
| settings=ModelSettings(), |
| store=store, |
| cache=cache, |
| ) |
| forced = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic("Forced run.")]), |
| settings=ModelSettings(), |
| store=store, |
| cache=cache, |
| force_reprocess=True, |
| ) |
|
|
| assert first.summary.cache_misses == 1 |
| assert second.summary.cache_hits == 1 |
| assert second.results[0].processing_metadata.cache_used is True |
| assert second.results[0].processing_status is ArticleProcessingStatus.COMPLETED |
| assert forced.summary.cache_hits == 0 |
| assert forced.summary.cache_misses == 1 |
| assert forced.results[0].no_classification_reason == "Forced run." |
|
|
|
|
| def test_batch_output_summary_validates_against_schema(tmp_path: Path) -> None: |
| load_result = validate_article_records( |
| [{"DOI": "10.example/schema", "Title": "Schema", "Year": 2025, "Abstract": ""}] |
| ) |
| batch = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic()]), |
| settings=ModelSettings(), |
| store=JsonResultStore(tmp_path / "results"), |
| ) |
|
|
| schema = json.loads(RUN_SUMMARY_SCHEMA_PATH.read_text()) |
| Draft202012Validator(schema).validate(batch.summary.model_dump(mode="json")) |
|
|
|
|
| def test_structured_diagnostics_do_not_include_secrets() -> None: |
| details = sanitize_log_details( |
| { |
| "DOI": "10.example/redacted", |
| "api_key": "raw-secret-value", |
| "nested": {"token": "raw-secret-value"}, |
| } |
| ) |
|
|
| assert details["api_key"] == "[REDACTED]" |
| assert details["nested"]["token"] == "[REDACTED]" |
| assert "raw-secret-value" not in json.dumps(details) |
|
|
|
|
| def test_model_retry_counts_and_cache_flags_appear_in_metadata(tmp_path: Path) -> None: |
| load_result = validate_article_records( |
| [{"DOI": "10.example/meta", "Title": "Meta", "Year": 2025, "Abstract": ""}] |
| ) |
| batch = run_batch( |
| article_load_result=load_result, |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic()]), |
| settings=ModelSettings(max_retries=3), |
| store=JsonResultStore(tmp_path / "results"), |
| ) |
|
|
| metadata = batch.results[0].processing_metadata |
| assert metadata.model_parameters["max_retries"] == 3 |
| assert metadata.cache_used is False |
| assert metadata.model_calls == 1 |
|
|
|
|
| def test_full_data_smoke_reports_current_invalid_article_without_modifying_sources( |
| tmp_path: Path, |
| ) -> None: |
| hierarchy_before = FULL_HIERARCHY_PATH.read_bytes() |
| articles_before = FULL_ARTICLES_PATH.read_bytes() |
| raw_articles = json.loads(articles_before) |
| load_result = validate_article_records(raw_articles) |
| subset_load_result = load_result.model_copy(update={"articles": load_result.articles[:1]}) |
|
|
| batch = run_batch( |
| article_load_result=subset_load_result, |
| vocabulary=load_vocabulary(FULL_HIERARCHY_PATH), |
| model_client=FakeModelClient([_no_topic()]), |
| settings=ModelSettings(), |
| store=JsonResultStore(tmp_path / "results"), |
| ) |
|
|
| assert isinstance(raw_articles, list) |
| assert load_result.source_count == len(raw_articles) |
| assert len(load_result.articles) == len(raw_articles) - len(load_result.errors) |
| assert len({article.DOI for article in load_result.articles}) == len(load_result.articles) |
| if load_result.errors: |
| assert all(error.code and error.message for error in load_result.errors) |
| assert all( |
| error.index is None or 0 <= error.index < len(raw_articles) |
| for error in load_result.errors |
| ) |
| assert batch.summary.articles_received == len(raw_articles) |
| assert batch.summary.invalid_source_records == len(load_result.errors) |
| assert batch.summary.processed_articles == 1 |
| assert FULL_HIERARCHY_PATH.read_bytes() == hierarchy_before |
| assert FULL_ARTICLES_PATH.read_bytes() == articles_before |
|
|
|
|
| def test_logging_records_processing_events_without_secrets(caplog) -> None: |
| logger = logging.getLogger("gcmd_classifier.tests.pipeline") |
| caplog.set_level(logging.INFO, logger=logger.name) |
|
|
| classify_article( |
| article=_article(), |
| vocabulary=_index(), |
| model_client=FakeModelClient([_no_topic()]), |
| settings=ModelSettings(), |
| logger=logger, |
| relevant_config={"api_key": "should-not-log"}, |
| ) |
|
|
| assert any(record.__dict__.get("event") == "article_started" for record in caplog.records) |
| serialized = "\n".join(str(record.__dict__) for record in caplog.records) |
| assert "should-not-log" not in serialized |
|
|