"""Per-tenant writing style profile cache. Style profiles are persisted as JSON files under ``settings.cache_dir / styles/`` so they survive server restarts. Each file is named ``.json``. Cached profiles expire after ``_STYLE_CACHE_TTL_DAYS`` days so that newly uploaded documents can shift the detected style on the next generation call. """ import json import logging import time from pathlib import Path from app.config import settings from app.models.schemas import WritingStyleProfile logger = logging.getLogger(__name__) # Style profiles are re-analysed after this many days of inactivity. # Set to 0 to disable TTL (cache lives forever until explicitly invalidated). _STYLE_CACHE_TTL_DAYS: int = 7 _STYLE_CACHE_TTL_SECS: float = _STYLE_CACHE_TTL_DAYS * 86_400 def _style_dir() -> Path: d = Path(settings.cache_dir) / "styles" d.mkdir(parents=True, exist_ok=True) return d def _path(tenant_id: str) -> Path: safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in tenant_id) return _style_dir() / f"{safe}.json" def get(tenant_id: str) -> WritingStyleProfile | None: """Return a cached :class:`WritingStyleProfile` for ``tenant_id``, or ``None``. Returns ``None`` (forcing a fresh analysis) when: * No cached file exists. * The cached file is older than ``_STYLE_CACHE_TTL_DAYS``. * The cached file is corrupt / unreadable. Args: tenant_id: The tenant whose style profile to look up. Returns: :class:`WritingStyleProfile` if cached and fresh, otherwise ``None``. """ p = _path(tenant_id) if not p.exists(): return None # TTL check — expire stale profiles so new uploads can shift the style. if _STYLE_CACHE_TTL_SECS > 0: age = time.time() - p.stat().st_mtime if age > _STYLE_CACHE_TTL_SECS: logger.debug( "Style cache expired (%.0f days old) for tenant=%s — will re-analyse", age / 86_400, tenant_id, ) p.unlink(missing_ok=True) return None try: data = json.loads(p.read_text(encoding="utf-8")) return WritingStyleProfile(**data) except Exception as exc: logger.warning("Could not read style cache for %s: %s", tenant_id, exc) return None def set(tenant_id: str, profile: WritingStyleProfile) -> None: """Persist ``profile`` for ``tenant_id`` atomically. Writes to a temp file then renames so a crash mid-write never corrupts the stored profile. Args: tenant_id: The tenant whose profile to store. profile: :class:`WritingStyleProfile` to serialise and save. """ p = _path(tenant_id) tmp = p.with_suffix(".tmp") try: tmp.write_text(profile.model_dump_json(indent=2), encoding="utf-8") tmp.replace(p) # atomic rename logger.debug("Style profile cached for tenant=%s", tenant_id) except Exception as exc: logger.warning("Could not write style cache for %s: %s", tenant_id, exc) tmp.unlink(missing_ok=True) def invalidate(tenant_id: str) -> None: """Remove the cached profile for ``tenant_id`` (force re-analysis on next call). Args: tenant_id: The tenant whose cache entry to delete. """ p = _path(tenant_id) if p.exists(): p.unlink(missing_ok=True) logger.debug("Style cache invalidated for tenant=%s", tenant_id)