GCMD_Keyword_Classifier_MVP / tests /test_pipeline.py
igerasimov's picture
Fixed smoke tests
08942f5
Raw
History Blame Contribute Delete
15.5 kB
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