GCMD_Keyword_Classifier_MVP / tests /test_mvp_smoke.py
igerasimov's picture
Fixed smoke tests
08942f5
Raw
History Blame Contribute Delete
8 kB
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator
from gcmd_classifier.articles import load_articles
from gcmd_classifier.config import ModelSettings
from gcmd_classifier.llm import FakeModelClient
from gcmd_classifier.llm.openai_provider import OpenAIModelClient
from gcmd_classifier.models import (
ArticleClassificationOutcome,
ArticleLoadResult,
ArticleProcessingStatus,
)
from gcmd_classifier.persistence import ArticleResultCache, JsonResultStore
from gcmd_classifier.pipeline import run_batch
from gcmd_classifier.vocabulary import VocabularyIndex, load_vocabulary
FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json")
FULL_ARTICLES_PATH = Path("data/articles.json")
PROTOTYPE_PATH = Path("prototype/app_hf_poc.py")
CLASSIFICATION_SCHEMA_PATH = Path("schemas/classification_result.schema.json")
RUN_SUMMARY_SCHEMA_PATH = Path("schemas/run_summary.schema.json")
def _decision(candidate_id: str) -> dict:
return {
"candidate_id": candidate_id,
"confidence": 0.9,
"evidence": f"Fake smoke evidence for {candidate_id}.",
"support_type": "explicit",
"reason": f"Fake smoke selected {candidate_id}.",
}
def _topic_response(candidate_id: str) -> dict:
return {"selected": [_decision(candidate_id)]}
def _term_response(candidate_id: str) -> dict:
return {"selected": [_decision(candidate_id)], "stop_at_parent": False}
def _variable_response(candidate_id: str = "variable_0001") -> dict:
return {"selected": [_decision(candidate_id)], "stop_at_parent": False}
def _no_topic_response() -> dict:
return {
"selected": [],
"no_selection_reason": "Fake smoke no-classification article.",
}
def _fake_smoke_actions(index: VocabularyIndex) -> list[dict]:
topic_position, topic, term_position, term = _first_term_with_variables(index)
actions = [
_topic_response(f"topic_{topic_position:04d}"),
_term_response(f"term_{term_position:04d}"),
]
parent = term
while parent.level != "Variable_Level_3":
children = index.variables_for_parent(parent.UUID)
if not children:
break
actions.append(_variable_response("variable_0001"))
parent = children[0]
actions.append(_no_topic_response())
return actions
def _first_term_with_variables(index: VocabularyIndex):
for topic_position, topic in enumerate(index.topics(), start=1):
for term_position, term in enumerate(index.terms_for_topic(topic.UUID), start=1):
if index.variables_for_parent(term.UUID):
return topic_position, topic, term_position, term
raise AssertionError("Current vocabulary did not contain a Term with Variable children.")
def _smoke_article_load_result(full_load: ArticleLoadResult) -> ArticleLoadResult:
return full_load.model_copy(update={"articles": full_load.articles[:2]})
def test_fake_model_mvp_smoke_run_end_to_end(tmp_path: Path) -> None:
hierarchy_before = FULL_HIERARCHY_PATH.read_bytes()
articles_before = FULL_ARTICLES_PATH.read_bytes()
prototype_before = PROTOTYPE_PATH.read_bytes()
classification_schema = json.loads(CLASSIFICATION_SCHEMA_PATH.read_text())
summary_schema = json.loads(RUN_SUMMARY_SCHEMA_PATH.read_text())
vocabulary = load_vocabulary(FULL_HIERARCHY_PATH)
raw_records = json.loads(FULL_ARTICLES_PATH.read_text())
full_load = load_articles(FULL_ARTICLES_PATH)
smoke_load = _smoke_article_load_result(full_load)
cache = ArticleResultCache(tmp_path / "cache")
store = JsonResultStore(tmp_path / "results")
batch = run_batch(
article_load_result=smoke_load,
vocabulary=vocabulary,
model_client=FakeModelClient(_fake_smoke_actions(vocabulary)),
settings=ModelSettings(),
store=store,
cache=cache,
run_id="fake-smoke",
relevant_config={"smoke_subset_size": len(smoke_load.articles)},
)
assert len(vocabulary) == 3535
assert isinstance(raw_records, list)
assert full_load.source_count == len(raw_records)
assert len(full_load.articles) == len(raw_records) - len(full_load.errors)
assert len({article.DOI for article in full_load.articles}) == len(full_load.articles)
if full_load.errors:
assert all(error.code and error.message for error in full_load.errors)
assert all(
error.index is None or 0 <= error.index < len(raw_records) for error in full_load.errors
)
assert batch.summary.articles_received == len(raw_records)
assert batch.summary.invalid_source_records == len(full_load.errors)
assert batch.summary.processed_articles == 2
assert batch.summary.cache_misses == 2
assert batch.summary.cache_hits == 0
assert batch.summary.total_model_calls and batch.summary.total_model_calls > 0
assert batch.summary.duration_seconds is not None
assert batch.summary.started_at is not None
assert batch.summary.completed_at is not None
classified = [
result
for result in batch.results
if result.classification_outcome is ArticleClassificationOutcome.CLASSIFIED
]
not_classified = [
result
for result in batch.results
if result.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED
]
assert classified
assert not_classified
assert not_classified[0].processing_status is ArticleProcessingStatus.COMPLETED
assert not_classified[0].classifications == ()
assert not_classified[0].no_classification_reason
accepted = tuple(record for result in batch.results for record in result.classifications)
assert accepted
assert batch.summary.accepted_classifications == len(accepted)
assert all(record.deterministic_validation.valid for record in accepted)
assert not any(
record.final_status == "accepted" and not record.deterministic_validation.valid
for record in accepted
)
article_validator = Draft202012Validator(classification_schema)
for result in batch.results:
article_validator.validate(result.model_dump(mode="json"))
Draft202012Validator(summary_schema).validate(batch.summary.model_dump(mode="json"))
assert store.consolidated_path.exists()
persisted = json.loads(store.consolidated_path.read_text())
assert persisted["summary"]["run_id"] == "fake-smoke"
assert len(persisted["articles"]) == 2
assert "prototype.app_hf_poc" not in sys.modules
assert FULL_HIERARCHY_PATH.read_bytes() == hierarchy_before
assert FULL_ARTICLES_PATH.read_bytes() == articles_before
assert PROTOTYPE_PATH.read_bytes() == prototype_before
@pytest.mark.integration
def test_optional_live_model_smoke_is_skipped_unless_explicitly_enabled(tmp_path: Path) -> None:
if os.environ.get("GCMD_RUN_LIVE_INTEGRATION") != "1":
pytest.skip("Set GCMD_RUN_LIVE_INTEGRATION=1 to enable live model smoke checks.")
settings = ModelSettings.from_environment()
if settings.provider != "openai":
pytest.skip("Set MODEL_PROVIDER=openai for live model smoke checks.")
if not os.environ.get(settings.api_key_env_var):
pytest.skip(f"Set {settings.api_key_env_var} for live model smoke checks.")
vocabulary = load_vocabulary(FULL_HIERARCHY_PATH)
full_load = load_articles(FULL_ARTICLES_PATH)
smoke_load = full_load.model_copy(update={"articles": full_load.articles[:1]})
batch = run_batch(
article_load_result=smoke_load,
vocabulary=vocabulary,
model_client=OpenAIModelClient(settings),
settings=settings,
store=JsonResultStore(tmp_path / "live-results"),
run_id="live-smoke",
)
accepted = tuple(record for result in batch.results for record in result.classifications)
assert all(record.deterministic_validation.valid for record in accepted)
assert batch.summary.processed_articles == 1