tts-voice-creator-clone-and.../core/vision_cache.py
2026-10-03 11:09:17 +02:00

140 lines
5.3 KiB
Python

"""Record/replay cache for vision-model (OCR) responses, so OCR/layout A/B runs are comparable.
A live vision model is not repeatable (model swaps, server-side sampling, load-dependent output), so two
imports of one PDF differ even when only the post-processing changed. This module freezes the raw
completion text per request:
* ``live`` (default) -- no cache; behaviour is exactly as before.
* ``record`` -- call the model, store the response, return it.
* ``replay`` -- never call the model; a request with no stored response raises ``VisionCacheMiss``
(fail loudly rather than silently mixing frozen and fresh output).
Select with ``MAGAZINE_VISION_CACHE=live|record|replay``; the directory is
``MAGAZINE_VISION_CACHE_DIR`` (default ``<CONFIG_DIR or ./config>/vision_cache``; ``config/`` is git-ignored).
The key is sha256 over (model, prompt text, sha256 of the image bytes, all other request parameters such as
temperature, max_tokens, seed and vendor fields). Endpoint URL and timeouts are not part of it (same model
behind another address is the same model). Each entry stores the key inputs, the response and a timestamp.
Every non-live run keeps ``last_run.json`` (mode, models, hits/recorded/misses) in the cache directory for
comparison reports.
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
import time
from pathlib import Path
from typing import Any, Callable
MODES = ("live", "record", "replay")
ENV_MODE = "MAGAZINE_VISION_CACHE"
ENV_DIR = "MAGAZINE_VISION_CACHE_DIR"
class VisionCacheMiss(RuntimeError):
"""Replay mode asked for a response that was never recorded."""
def get_mode() -> str:
mode = (os.environ.get(ENV_MODE) or "live").strip().lower()
if mode not in MODES:
raise ValueError(f"{ENV_MODE}={mode!r} is invalid; use one of {', '.join(MODES)}")
return mode
def cache_dir() -> Path:
env = os.environ.get(ENV_DIR)
if env:
return Path(env)
base = os.environ.get("CONFIG_DIR") or str(Path(__file__).resolve().parents[1] / "config")
return Path(base) / "vision_cache"
def make_key(model: str, prompt: str, image_bytes: bytes, params: dict[str, Any]) -> tuple[str, dict[str, Any]]:
inputs = {
"model": model,
"prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"image_sha256": hashlib.sha256(image_bytes).hexdigest(),
"params": params,
}
blob = json.dumps(inputs, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest(), inputs
class _Stats:
def __init__(self) -> None:
self.lock = threading.Lock()
self.models: set[str] = set()
self.hits = self.recorded = self.misses = 0
self.started = time.strftime("%Y-%m-%dT%H:%M:%S%z")
def snapshot(self, mode: str) -> dict[str, Any]:
return {"mode": mode, "models": sorted(self.models), "hits": self.hits, "recorded": self.recorded,
"misses": self.misses, "started": self.started}
_stats = _Stats()
def run_summary() -> dict[str, Any]:
"""What this process did with the cache so far (for reports)."""
with _stats.lock:
return _stats.snapshot(get_mode())
def _write_manifest(directory: Path, mode: str) -> None:
try:
directory.mkdir(parents=True, exist_ok=True)
tmp = directory / "last_run.json.tmp"
tmp.write_text(json.dumps(_stats.snapshot(mode), indent=1), "utf-8")
tmp.replace(directory / "last_run.json")
except OSError:
pass # the manifest is a convenience; never fail an OCR call over it
def cached_completion(model: str, prompt: str, image_bytes: bytes, params: dict[str, Any],
fetch: Callable[[], str | None]) -> str | None:
"""Return the completion text for this request according to the cache mode.
``fetch`` performs the real call (live/record only) and returns the text, or None on failure;
failures are never stored.
"""
mode = get_mode()
if mode == "live":
return fetch()
directory = cache_dir()
key, inputs = make_key(model, prompt, image_bytes, params)
path = directory / f"{key}.json"
with _stats.lock:
_stats.models.add(model)
if mode == "replay":
try:
entry = json.loads(path.read_text("utf-8"))
content = entry["response"]
except (OSError, ValueError, KeyError):
with _stats.lock:
_stats.misses += 1
_write_manifest(directory, mode)
raise VisionCacheMiss(
f"no recorded vision response for model {model!r} (key {key[:12]}, image "
f"{inputs['image_sha256'][:12]}) in {directory}; record it first with {ENV_MODE}=record")
with _stats.lock:
_stats.hits += 1
_write_manifest(directory, mode)
return content
content = fetch()
if content is not None:
directory.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps({"key_inputs": inputs, "response": content,
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z")},
ensure_ascii=False, indent=1), "utf-8")
tmp.replace(path)
with _stats.lock:
_stats.recorded += 1
with _stats.lock:
_write_manifest(directory, mode)
return content