igerasimov's picture
MVP Milestone 10
e726170
Raw
History Blame Contribute Delete
4.88 kB
"""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)