| """Cache identity and file-backed result cache for article classifications.""" |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import os |
| from pathlib import Path |
| from typing import Any |
|
|
| from pydantic import BaseModel, ConfigDict, Field |
|
|
| from gcmd_classifier.config import ModelSettings |
| from gcmd_classifier.models import ArticleRecord, ArticleResult |
| from gcmd_classifier.vocabulary.index import VocabularyIndex |
|
|
|
|
| class CacheIdentity(BaseModel): |
| """All inputs that can affect reuse of a cached article result.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| DOI: str |
| article_fingerprint: str |
| vocabulary_version: str |
| model_provider: str |
| model_name: str |
| model_temperature: float |
| model_timeout_seconds: float |
| model_max_retries: int |
| prompt_version_topic: str |
| prompt_version_term: str |
| prompt_version_variable: str |
| application_version: str |
| configuration_hash: str |
|
|
| @property |
| def cache_key(self) -> str: |
| """Stable cache key derived from the complete identity.""" |
| payload = json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) |
| return hashlib.sha256(payload.encode("utf-8")).hexdigest() |
|
|
|
|
| class CachedArticleResult(BaseModel): |
| """Persisted cache entry containing identity and article result.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| cache_key: str = Field(min_length=1) |
| identity: CacheIdentity |
| result: ArticleResult |
|
|
|
|
| class ArticleResultCache: |
| """Simple file-backed article result cache keyed by cache identity.""" |
|
|
| def __init__(self, directory: str | Path) -> None: |
| self.directory = Path(directory) |
| self.directory.mkdir(parents=True, exist_ok=True) |
|
|
| def get(self, identity: CacheIdentity) -> ArticleResult | None: |
| """Return a completed cached result for the exact identity, if present.""" |
| path = self._path(identity.cache_key) |
| if not path.exists(): |
| return None |
| entry = CachedArticleResult.model_validate(json.loads(path.read_text())) |
| if entry.cache_key != identity.cache_key or entry.identity != identity: |
| return None |
| return mark_cache_used(entry.result) |
|
|
| def put(self, identity: CacheIdentity, result: ArticleResult) -> None: |
| """Persist a result for later exact-identity reuse.""" |
| entry = CachedArticleResult( |
| cache_key=identity.cache_key, |
| identity=identity, |
| result=result, |
| ) |
| _atomic_write_json(self._path(identity.cache_key), entry.model_dump(mode="json")) |
|
|
| def _path(self, cache_key: str) -> Path: |
| return self.directory / f"{cache_key}.json" |
|
|
|
|
| def article_fingerprint(article: ArticleRecord) -> str: |
| """Hash exact source article values that influence classification.""" |
| payload = json.dumps(article.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) |
| return hashlib.sha256(payload.encode("utf-8")).hexdigest() |
|
|
|
|
| def configuration_hash(config: dict[str, Any] | None = None) -> str: |
| """Hash relevant non-secret configuration values.""" |
| payload = json.dumps({} if config is None else config, sort_keys=True, separators=(",", ":")) |
| return hashlib.sha256(payload.encode("utf-8")).hexdigest() |
|
|
|
|
| def build_cache_identity( |
| *, |
| article: ArticleRecord, |
| vocabulary: VocabularyIndex, |
| settings: ModelSettings, |
| application_version: str, |
| relevant_config: dict[str, Any] | None = None, |
| ) -> CacheIdentity: |
| """Build the complete cache identity for one article classification run.""" |
| return CacheIdentity( |
| DOI=article.DOI, |
| article_fingerprint=article_fingerprint(article), |
| vocabulary_version=vocabulary.vocabulary_version, |
| model_provider=settings.provider, |
| model_name=settings.model_name, |
| model_temperature=settings.temperature, |
| model_timeout_seconds=settings.timeout_seconds, |
| model_max_retries=settings.max_retries, |
| prompt_version_topic=settings.prompt_version_topic, |
| prompt_version_term=settings.prompt_version_term, |
| prompt_version_variable=settings.prompt_version_variable, |
| application_version=application_version, |
| configuration_hash=configuration_hash(relevant_config), |
| ) |
|
|
|
|
| def mark_cache_used(result: ArticleResult) -> ArticleResult: |
| """Return a cached copy of a result marked as completed work from cache.""" |
| metadata = result.processing_metadata.model_copy(update={"cache_used": True}) |
| return result.model_copy(update={"processing_metadata": metadata}) |
|
|
|
|
| def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| temp_path = path.with_name(f".{path.name}.tmp") |
| temp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") |
| os.replace(temp_path, path) |
|
|