File size: 3,447 Bytes
0136798
 
 
 
3c31a2a
 
 
0136798
 
 
 
3c31a2a
0136798
 
 
 
 
 
 
3c31a2a
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c31a2a
 
 
 
 
0136798
 
 
 
3c31a2a
0136798
 
 
 
3c31a2a
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
3c31a2a
 
 
 
0136798
 
 
 
 
 
3c31a2a
0136798
3c31a2a
 
0136798
 
 
3c31a2a
0136798
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
"""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 ``<tenant_id>.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)