docling-studio/document-parser/infra/settings.py
Pier-Jean Malandrino 7351159eeb feat(#210): feature-flag mode gating + deep-link redirect
Backend
- HealthResponse exposes inspectModeEnabled / chunksModeEnabled /
  askModeEnabled (additive; defaults true). main.py /api/health
  populates them from settings.
- infra/settings.py: three new env-var-driven booleans (defaults true)
  parsed in from_env() like the existing reasoning_enabled flag.
- tests/test_api_endpoints.py: extra assertion that /api/health
  surfaces the three new fields with their defaults.

Frontend — flag store
- features/feature-flags/store.ts: FeatureFlag union extended with
  inspectMode / chunksMode / askMode. New entries in featureRegistry
  are gated on context fields populated from health. Missing fields
  fall back to true so a frontend pointed at an older backend keeps
  every mode visible.
- store gains a modeFlags() helper returning Record<DocMode, boolean>
  so the routing guard does not need to know the FeatureFlag union.

Frontend — routing
- shared/routing/resolveMode.ts: pure resolver. If the requested mode
  is enabled, return it; else first enabled in priority ask > chunks
  > inspect; else null.
- app/router/index.ts: beforeEach guard scoped to ROUTES.DOC_WORKSPACE.
  Disabled mode → rewrite ?mode= to the first enabled one. All three
  off → redirect to /docs?reason=no-mode-enabled.

Frontend — flash
- pages/DocsLibraryPage.vue: shows a banner when ?reason=no-mode-enabled
  is set. #211 will move this into the proper library page banner.
- i18n flags.allModesDisabled added in fr + en.

Tests
- shared/routing/resolveMode.test.ts (6 cases): every (requested,
  enabled) combination including all-disabled, priority order,
  missing requested.
- features/feature-flags/store.test.ts: three new cases covering the
  new fields in /api/health, fall-back-to-true on missing fields, and
  modeFlags() shape.

Refs #210
2026-04-29 17:53:55 +02:00

173 lines
9.6 KiB
Python

"""Centralized application settings — loaded from environment variables."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Settings:
app_version: str = "dev"
conversion_engine: str = "local" # "local" or "remote"
deployment_mode: str = "self-hosted" # "self-hosted" or "huggingface"
docling_serve_url: str = "http://localhost:5001"
docling_serve_api_key: str | None = None
conversion_timeout: int = 900
document_timeout: float = 120.0 # Docling-level per-document timeout (seconds)
lock_timeout: int = 300 # converter lock acquisition timeout (seconds)
max_concurrent_analyses: int = 3
default_table_mode: str = "accurate" # "accurate" or "fast"
max_page_count: int = 0 # 0 = unlimited (upload validation)
max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes)
max_file_size_mb: int = 50 # upload limit in MB (0 = unlimited)
rate_limit_rpm: int = 100 # requests per minute per IP (0 = disabled)
batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch
opensearch_url: str = "" # empty = disabled
embedding_url: str = "" # empty = disabled (e.g. http://localhost:8001)
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
neo4j_user: str = "neo4j"
# DEV DEFAULT — the dev compose stack uses "changeme" so `docker compose
# up` works out of the box. The backend logs a loud warning at boot if
# Neo4j is wired (NEO4J_URI set) AND the password is still the default,
# so prod operators notice if they inherited it by accident. Real
# deployments must override NEO4J_PASSWORD.
neo4j_password: str = "changeme"
# Live reasoning via docling-agent — off by default (heavy deps, needs an
# Ollama host reachable from the backend). Toggle REASONING_ENABLED=true +
# point OLLAMA_HOST at a running instance (default http://localhost:11434).
reasoning_enabled: bool = False
# LLM backend the reasoning runner talks to. Today only "ollama" is
# realizable (docling-agent is hardwired to Ollama via mellea); kept as a
# config knob to make the LLMProvider abstraction visible and prepare the
# ground for additional backends.
llm_provider_type: str = "ollama"
ollama_host: str = "http://localhost:11434"
reasoning_model_id: str = "gpt-oss:20b" # matches docling-agent's example_05
opensearch_default_limit: int = 1000 # max chunks returned by get_chunks
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db"
max_paste_image_size_mb: int = 10 # clipboard-paste image limit in MB (0 = unlimited)
paste_allowed_image_types: list[str] = field(
default_factory=lambda: ["image/png", "image/jpeg", "image/webp"]
)
cors_origins: list[str] = field(
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
)
# 0.6.0 — Doc workspace mode flags (#210). All on by default to preserve
# existing behaviour; operators flip a flag off to hide a mode tab + redirect
# deep links. Per-tenant gating is out of scope for 0.6.0.
inspect_mode_enabled: bool = True
chunks_mode_enabled: bool = True
ask_mode_enabled: bool = True
def __post_init__(self) -> None:
errors: list[str] = []
if self.document_timeout <= 0:
errors.append(f"document_timeout must be > 0 (got {self.document_timeout})")
if self.conversion_timeout <= 0:
errors.append(f"conversion_timeout must be > 0 (got {self.conversion_timeout})")
if self.lock_timeout <= 0:
errors.append(f"lock_timeout must be > 0 (got {self.lock_timeout})")
if self.max_concurrent_analyses < 1:
errors.append(
f"max_concurrent_analyses must be >= 1 (got {self.max_concurrent_analyses})"
)
if self.max_page_count < 0:
errors.append(f"max_page_count must be >= 0 (got {self.max_page_count})")
if self.max_file_size < 0:
errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})")
if self.max_file_size_mb < 0:
errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})")
if self.max_paste_image_size_mb < 0:
errors.append(
f"max_paste_image_size_mb must be >= 0 (got {self.max_paste_image_size_mb})"
)
if not self.paste_allowed_image_types:
errors.append("paste_allowed_image_types must not be empty")
if self.rate_limit_rpm < 0:
errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})")
if self.batch_page_size < 0:
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
if self.opensearch_default_limit < 1:
errors.append(
f"opensearch_default_limit must be >= 1 (got {self.opensearch_default_limit})"
)
if self.embedding_dimension < 1:
errors.append(f"embedding_dimension must be >= 1 (got {self.embedding_dimension})")
if self.default_table_mode not in ("accurate", "fast"):
errors.append(
f"default_table_mode must be 'accurate' or 'fast' (got '{self.default_table_mode}')"
)
# Timeout cascade: document_timeout < lock_timeout < conversion_timeout
if self.document_timeout > 0 and self.lock_timeout > 0 and self.conversion_timeout > 0:
if self.document_timeout >= self.lock_timeout:
errors.append(
f"document_timeout ({self.document_timeout}s) must be "
f"< lock_timeout ({self.lock_timeout}s)"
)
if self.lock_timeout >= self.conversion_timeout:
errors.append(
f"lock_timeout ({self.lock_timeout}s) must be "
f"< conversion_timeout ({self.conversion_timeout}s)"
)
if errors:
raise ValueError("Invalid settings:\n " + "\n ".join(errors))
@classmethod
def from_env(cls) -> Settings:
"""Build a Settings instance from environment variables."""
cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173")
paste_types_raw = os.environ.get(
"PASTE_ALLOWED_IMAGE_TYPES", "image/png,image/jpeg,image/webp"
)
return cls(
app_version=os.environ.get("APP_VERSION", "dev"),
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
deployment_mode=os.environ.get("DEPLOYMENT_MODE", "self-hosted"),
docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"),
docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"),
conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "900")),
document_timeout=float(os.environ.get("DOCUMENT_TIMEOUT", "120.0")),
lock_timeout=int(os.environ.get("LOCK_TIMEOUT", "300")),
max_concurrent_analyses=int(os.environ.get("MAX_CONCURRENT_ANALYSES", "3")),
default_table_mode=os.environ.get("DEFAULT_TABLE_MODE", "accurate"),
max_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")),
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
# 0 = batching disabled (matches dataclass default). Batching
# preserves memory on very large docs but `merge_results` drops
# `document_json`, which breaks the reasoning tunnel. Enable
# explicitly (e.g. 50+) for memory-bound deploys.
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
embedding_url=os.environ.get("EMBEDDING_URL", ""),
neo4j_uri=os.environ.get("NEO4J_URI", ""),
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
neo4j_password=os.environ.get("NEO4J_PASSWORD", "changeme"),
reasoning_enabled=os.environ.get("REASONING_ENABLED", "false").lower()
in ("1", "true", "yes", "on"),
llm_provider_type=os.environ.get("LLM_PROVIDER_TYPE", "ollama"),
ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
reasoning_model_id=os.environ.get("REASONING_MODEL_ID", "gpt-oss:20b"),
opensearch_default_limit=int(os.environ.get("OPENSEARCH_DEFAULT_LIMIT", "1000")),
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")),
paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()],
cors_origins=[o.strip() for o in cors_raw.split(",")],
# 0.6.0 — Doc workspace mode flags (#210). Defaults: enabled.
inspect_mode_enabled=os.environ.get("INSPECT_MODE_ENABLED", "true").lower()
in ("1", "true", "yes", "on"),
chunks_mode_enabled=os.environ.get("CHUNKS_MODE_ENABLED", "true").lower()
in ("1", "true", "yes", "on"),
ask_mode_enabled=os.environ.get("ASK_MODE_ENABLED", "true").lower()
in ("1", "true", "yes", "on"),
)
# Module-level singleton — import this from other modules.
settings = Settings.from_env()