from __future__ import annotations from pathlib import Path from gcmd_classifier.config import ModelSettings from gcmd_classifier.models import ( ArticleClassificationOutcome, ArticleProcessingStatus, ArticleRecord, ArticleResult, ProcessingMetadata, ReviewStatus, ) from gcmd_classifier.persistence import ArticleResultCache, build_cache_identity from gcmd_classifier.vocabulary import load_vocabulary FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json") def _article(**overrides: object) -> ArticleRecord: values = { "DOI": "10.example/cache", "Title": "Carbon dioxide observations", "Year": 2025, "Abstract": "Atmospheric carbon dioxide profiles are discussed.", } values.update(overrides) return ArticleRecord.model_validate(values) def _result(article: ArticleRecord) -> ArticleResult: return ArticleResult( DOI=article.DOI, Title=article.Title, Year=article.Year, Abstract=article.Abstract, processing_status=ArticleProcessingStatus.COMPLETED, classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED, classifications=(), no_classification_reason="No Topic selected.", review_status=ReviewStatus.NOT_REQUIRED, processing_metadata=ProcessingMetadata(cache_used=False), ) def _identity(article: ArticleRecord, settings: ModelSettings | None = None, **kwargs): return build_cache_identity( article=article, vocabulary=load_vocabulary(FIXTURE_PATH), settings=settings or ModelSettings(), application_version=kwargs.pop("application_version", "0.1.0"), relevant_config=kwargs.pop("relevant_config", None), ) def test_cache_key_includes_article_fingerprint() -> None: original = _identity(_article()) changed = _identity(_article(Title="Changed title")) assert original.article_fingerprint != changed.article_fingerprint assert original.cache_key != changed.cache_key def test_cache_key_changes_when_article_content_changes() -> None: assert ( _identity(_article(Abstract="A")).cache_key != _identity(_article(Abstract="B")).cache_key ) def test_cache_key_changes_when_vocabulary_hash_changes() -> None: identity = _identity(_article()) changed = identity.model_copy(update={"vocabulary_version": "different-vocab"}) assert identity.cache_key != changed.cache_key def test_cache_key_changes_when_model_provider_name_or_parameters_change() -> None: default = _identity(_article()) provider = _identity(_article(), ModelSettings(provider="other")) name = _identity(_article(), ModelSettings(model_name="other-model")) temperature = _identity(_article(), ModelSettings(temperature=0.2)) assert len({default.cache_key, provider.cache_key, name.cache_key, temperature.cache_key}) == 4 def test_cache_key_changes_when_prompt_versions_change() -> None: default = _identity(_article()) changed = _identity(_article(), ModelSettings(prompt_version_topic="topic-v2")) assert default.cache_key != changed.cache_key def test_cache_key_changes_when_relevant_config_changes() -> None: default = _identity(_article(), relevant_config={"candidate_limit": 100}) changed = _identity(_article(), relevant_config={"candidate_limit": 200}) assert default.cache_key != changed.cache_key def test_cache_hit_returns_cache_used_true_and_completed_status(tmp_path: Path) -> None: article = _article() identity = _identity(article) cache = ArticleResultCache(tmp_path) cache.put(identity, _result(article)) cached = cache.get(identity) assert cached is not None assert cached.processing_metadata.cache_used is True assert cached.processing_status is ArticleProcessingStatus.COMPLETED assert cached.processing_status is not ArticleProcessingStatus.SKIPPED def test_stale_cache_is_not_reused(tmp_path: Path) -> None: article = _article() identity = _identity(article) stale_identity = _identity(_article(Title="Changed title")) cache = ArticleResultCache(tmp_path) cache.put(identity, _result(article)) assert cache.get(stale_identity) is None