File size: 7,349 Bytes
082393b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
from __future__ import annotations

import logging
import os
import sys
import tempfile
from pathlib import Path
from typing import Any

from PIL import Image

logger = logging.getLogger(__name__)

_DEFAULT_STORAGE_PATH = "granite-cache"

_SUBDIRS = ("tmp", "sessions", "cache", "uploads", "models")

_MODEL_CACHE_ENV_VARS = {
    "HF_HOME": "models/huggingface",
    "DOCLING_CACHE_DIR": "models/docling",
    "TORCH_HOME": "models/torch",
    "EASYOCR_MODULE_PATH": "models/easyocr",
}


def get_storage_path(sub_path: str = "") -> Path:
    base = os.environ.get("STORAGE_PATH", "").strip()
    root = Path(base).resolve() if base else Path(tempfile.gettempdir())
    return root / sub_path if sub_path else root


def get_temp_dir() -> Path:
    return get_storage_path("tmp")


def init_storage() -> None:
    base = os.environ.get("STORAGE_PATH", "").strip()
    if base:
        root = Path(base).resolve()
        logger.info("STORAGE_PATH set: %s", root)
    else:
        root = Path(tempfile.gettempdir())
        logger.info("STORAGE_PATH not set — using system temp dir: %s", root)

    for sub in _SUBDIRS:
        d = root / sub
        try:
            d.mkdir(parents=True, exist_ok=True)
        except OSError as e:
            logger.error("Cannot create storage dir %s: %s", d, e)
            sys.exit(1)

    if base:
        for env_var, sub_path in _MODEL_CACHE_ENV_VARS.items():
            if env_var not in os.environ:
                cache_dir = root / sub_path
                cache_dir.mkdir(parents=True, exist_ok=True)
                os.environ[env_var] = str(cache_dir)
                logger.info("Set %s=%s", env_var, cache_dir)

    probe = root / "tmp" / ".write_test"
    try:
        probe.touch()
        probe.unlink()
    except OSError as e:
        logger.error("Storage path %s is not writable: %s", root, e)
        sys.exit(1)

    logger.info("Storage ready: %s  (subdirs: %s)", root, ", ".join(_SUBDIRS))


def get_sessions_path() -> Path:
    return get_storage_path("sessions")


def parse_cache_per_session() -> bool:
    """Return True when Docling parse results should be stored per-session.

    Controlled by ``PARSE_CACHE_PER_SESSION`` env var (default ``"true"``).
    Only meaningful in disk mode.
    """
    return os.environ.get("PARSE_CACHE_PER_SESSION", "true").strip().lower() != "false"


def use_disk_images() -> bool:
    """Return True when bulky session images should be externalized to disk.

    Controlled by ``SESSION_IMAGES`` env var: ``"disk"`` (default) saves
    PIL Images to disk and stores LazyImage proxies in session state;
    ``"memory"`` keeps real PIL Images in-memory (original behaviour).
    """
    return os.environ.get("SESSION_IMAGES", "disk").strip().lower() != "memory"


class LazyImage:
    """PIL Image proxy that loads from disk on every access.

    Stored in figure dict ``"image"`` slots after upload processing.
    Any PIL attribute access (e.g. ``.convert()``, ``.save()``, ``.size``)
    reads the PNG from disk each time so no PIL data stays in memory.
    """

    __slots__ = ("_path",)

    def __init__(self, path: Path) -> None:
        object.__setattr__(self, "_path", path)

    def _load(self) -> Image.Image:
        path = object.__getattribute__(self, "_path")
        return Image.open(path).copy()

    @property
    def disk_path(self) -> Path:
        return object.__getattribute__(self, "_path")

    def __getattr__(self, name: str) -> Any:
        return getattr(self._load(), name)

    def __repr__(self) -> str:
        path = object.__getattribute__(self, "_path")
        return f"<LazyImage {path.name}>"


def save_image(session_id: str, category: str, index: int, img: Image.Image) -> LazyImage | Image.Image:
    """Save a PIL Image to disk and return a LazyImage proxy, or pass through.

    When ``SESSION_STORAGE=memory``, returns *img* unchanged (original behaviour).
    Otherwise saves to ``STORAGE_PATH/sessions/<session_id>/<category>/`` and
    returns a :class:`LazyImage`.
    """
    if not use_disk_images():
        return img
    cat_dir = get_sessions_path() / session_id / category
    cat_dir.mkdir(parents=True, exist_ok=True)
    path = cat_dir / f"{index:04d}.png"
    img.save(path, format="PNG")
    return LazyImage(path)


def resolve_for_gradio(img: "LazyImage | Image.Image | None") -> "Path | Image.Image | None":
    """Return a value Gradio's gr.Image component can postprocess.

    LazyImage is an internal proxy unknown to Gradio; hand it the disk path
    instead (Gradio accepts str/Path for image files). PIL Images and None
    pass through unchanged.
    """
    if isinstance(img, LazyImage):
        return img.disk_path
    return img


def _parse_cache_dir(file_hash: str, session_id: str | None) -> Path:
    if session_id and parse_cache_per_session():
        return get_sessions_path() / session_id
    return get_storage_path("cache") / file_hash


def load_parse_cache(file_hash: str, session_id: str | None = None) -> dict | None:
    """Load a previously persisted Docling parse result from disk.

    Returns a dict with ``html``, ``text``, and an empty ``figures`` list,
    or ``None`` if no cached result exists or disk mode is off.
    """
    if not use_disk_images():
        return None
    import json
    path = _parse_cache_dir(file_hash, session_id) / "result.json"
    if not path.exists():
        return None
    data = json.loads(path.read_text())
    data["figures"] = []
    return data


def save_parse_cache(file_hash: str, result: dict, session_id: str | None = None) -> None:
    """Persist the html and text from a Docling parse result to disk.

    No-op when disk mode is off. Figures (PIL Images) are excluded.
    """
    if not use_disk_images():
        return
    import json
    cache_dir = _parse_cache_dir(file_hash, session_id)
    cache_dir.mkdir(parents=True, exist_ok=True)
    payload = {"html": result.get("html", ""), "text": result.get("text", "")}
    (cache_dir / "result.json").write_text(json.dumps(payload))


def save_upload(session_id: str, data: bytes, suffix: str) -> Path:
    """Save the original uploaded file into the session directory.

    Returns the path to the saved file.  When ``SESSION_IMAGES=memory``,
    returns ``None`` (caller should fall back to a temp file).
    """
    if not use_disk_images():
        return None
    session_dir = get_sessions_path() / session_id
    session_dir.mkdir(parents=True, exist_ok=True)
    path = session_dir / f"upload{suffix}"
    path.write_bytes(data)
    return path


def v1_mode() -> bool:
    """Return True when browser storage mode is enabled (v1 API)."""
    return os.environ.get("BROWSER_MEMORY_MODE", "").strip().lower() in ("1", "true", "yes")


def debug_keep_files() -> bool:
    """Return True when intermediate files should be preserved for debugging."""
    return os.environ.get("DEBUG_KEEP_FILES", "").strip().lower() in ("1", "true", "yes")


def cleanup_session_files(session_id: str) -> None:
    """Remove all files for a session from disk.

    No-op when DEBUG_KEEP_FILES is set or disk mode is off.
    """
    if debug_keep_files() or not use_disk_images():
        return
    import shutil

    session_dir = get_sessions_path() / session_id
    if session_dir.exists():
        shutil.rmtree(session_dir, ignore_errors=True)