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