bd26e9ce6e
Two faults found by running the pipeline inside the Docker image for the first time, both of which let a 25-page run finish "successfully" having stored nothing. The image declares ENV DASHSCOPE_API_KEY= so the variable is visible to docker inspect and overridable at docker run. That leaves it set-but-empty in every container, and load_dotenv used os.environ.setdefault, so the empty string won and the mounted .env was ignored. An empty value now counts as unset; a real value still wins, because overriding on the command line is the point. The consequence was worse than a missing key. Every page failed, the per-page fallback kept the font-mangled embedded text -- exactly the text OCR is there to replace -- and the run went on to spend a full detection pass on it. Tolerating one unreadable page is right; tolerating every page is a configuration error wearing a success message, so Document now raises OcrUnavailable when OCR was attempted and not one page came back, quoting the first real failure. A page range that needed no OCR at all is still not an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ci5T3gWDhhmqDec3e1XtYZ
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""Runtime configuration, read from the environment (and an optional .env file).
|
|
|
|
Everything the pipeline needs to talk to an LLM or to Lean is settable without
|
|
touching code, so the same image runs against any OpenAI-compatible endpoint and
|
|
any Lean project.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
DEFAULT_MODEL = "qwen-max"
|
|
DEFAULT_API_KEY_ENV = "DASHSCOPE_API_KEY"
|
|
|
|
|
|
def load_dotenv(path: Path | str = ".env") -> None:
|
|
"""Load ``KEY=VALUE`` lines into os.environ without overwriting a real value.
|
|
|
|
A variable that is set but *empty* counts as unset. This is not pedantry: the
|
|
Docker image declares ``ENV DASHSCOPE_API_KEY=`` so the variable is documented
|
|
and overridable at ``docker run``, which left it present-and-empty in every
|
|
container. With plain ``setdefault`` that empty string shadowed the .env file,
|
|
and the only symptom was every OCR page failing with "No API key" -- a whole
|
|
25-page run spent on text the pipeline had already judged unreadable.
|
|
"""
|
|
path = Path(path)
|
|
if not path.is_file():
|
|
return
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
key = key.strip()
|
|
if os.environ.get(key): # a non-empty value wins; empty or absent does not
|
|
continue
|
|
os.environ[key] = value.strip().strip('"').strip("'")
|
|
|
|
|
|
def _env(name: str, default: str) -> str:
|
|
value = os.environ.get(name)
|
|
return default if value is None or value == "" else value
|
|
|
|
|
|
def _env_int(name: str, default: int) -> int:
|
|
try:
|
|
return int(_env(name, str(default)))
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def _env_float(name: str, default: float) -> float:
|
|
try:
|
|
return float(_env(name, str(default)))
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
@dataclass
|
|
class LLMSettings:
|
|
model: str = DEFAULT_MODEL
|
|
base_url: str = DEFAULT_BASE_URL
|
|
api_key_env: str = DEFAULT_API_KEY_ENV
|
|
timeout: float = 300.0
|
|
max_retries: int = 4
|
|
temperature: float = 0.0
|
|
|
|
@classmethod
|
|
def from_env(cls) -> LLMSettings:
|
|
return cls(
|
|
model=_env("LEANBENCH_LLM_MODEL", DEFAULT_MODEL),
|
|
base_url=_env("LEANBENCH_LLM_BASE_URL", DEFAULT_BASE_URL),
|
|
api_key_env=_env("LEANBENCH_LLM_API_KEY_ENV", DEFAULT_API_KEY_ENV),
|
|
timeout=_env_float("LEANBENCH_LLM_TIMEOUT", 300.0),
|
|
max_retries=_env_int("LEANBENCH_LLM_MAX_RETRIES", 4),
|
|
temperature=_env_float("LEANBENCH_LLM_TEMPERATURE", 0.0),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class LeanSettings:
|
|
"""How to invoke Lean. ``command`` is an argv prefix that is given a .lean path."""
|
|
|
|
command: list[str] = field(default_factory=lambda: ["lake", "env", "lean"])
|
|
project: str | None = None
|
|
timeout: float = 120.0
|
|
header: str = "import Mathlib\n"
|
|
|
|
@classmethod
|
|
def from_env(cls) -> LeanSettings:
|
|
raw = _env("LEANBENCH_LEAN_COMMAND", '["lake","env","lean"]')
|
|
try:
|
|
command = json.loads(raw)
|
|
if not isinstance(command, list) or not all(isinstance(x, str) for x in command):
|
|
raise ValueError
|
|
except ValueError:
|
|
command = raw.split()
|
|
return cls(
|
|
command=command,
|
|
project=os.environ.get("LEANBENCH_LEAN_PROJECT") or None,
|
|
timeout=_env_float("LEANBENCH_LEAN_TIMEOUT", 120.0),
|
|
header=_env("LEANBENCH_LEAN_HEADER", "import Mathlib\n"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class OcrSettings:
|
|
"""Reading pages as pictures. Off by default; ``auto`` is the useful setting."""
|
|
|
|
mode: str = "text" # text | auto | ocr
|
|
backend: str = "vision" # vision | tesseract
|
|
model: str = "qwen-vl-max"
|
|
dpi: int = 200
|
|
threshold: float = 0.75 # below this quality, `auto` reaches for the camera
|
|
cache_dir: str = ".leanbench-cache/ocr"
|
|
language: str = "eng" # tesseract only
|
|
|
|
@classmethod
|
|
def from_env(cls) -> OcrSettings:
|
|
return cls(
|
|
mode=_env("LEANBENCH_EXTRACTOR", "text").lower(),
|
|
backend=_env("LEANBENCH_OCR_BACKEND", "vision").lower(),
|
|
model=_env("LEANBENCH_OCR_MODEL", "qwen-vl-max"),
|
|
dpi=_env_int("LEANBENCH_OCR_DPI", 200),
|
|
threshold=_env_float("LEANBENCH_OCR_THRESHOLD", 0.75),
|
|
cache_dir=_env("LEANBENCH_OCR_CACHE", ".leanbench-cache/ocr"),
|
|
language=_env("LEANBENCH_OCR_LANGUAGE", "eng"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Settings:
|
|
llm: LLMSettings = field(default_factory=LLMSettings)
|
|
lean: LeanSettings = field(default_factory=LeanSettings)
|
|
ocr: OcrSettings = field(default_factory=OcrSettings)
|
|
database: str = "database/leanbench.sqlite"
|
|
max_window_pages: int = 3
|
|
repair_attempts: int = 1
|
|
|
|
@classmethod
|
|
def from_env(cls, dotenv: Path | str | None = ".env") -> Settings:
|
|
if dotenv is not None:
|
|
load_dotenv(dotenv)
|
|
return cls(
|
|
llm=LLMSettings.from_env(),
|
|
lean=LeanSettings.from_env(),
|
|
ocr=OcrSettings.from_env(),
|
|
database=_env("LEANBENCH_DATABASE", "database/leanbench.sqlite"),
|
|
max_window_pages=_env_int("LEANBENCH_MAX_WINDOW_PAGES", 3),
|
|
repair_attempts=_env_int("LEANBENCH_REPAIR_ATTEMPTS", 1),
|
|
)
|