APP_BASE_URL default (config, .env.example, both compose files) is now https://conchecker.scoutitsystems.com with no port, so review-required and final-report email links use the public site. CI bakes the short commit SHA into the image as APP_BUILD via a Docker build-arg; /health returns version+build and the site header shows the build so it's easy to confirm which image is deployed. Local runs default to 'dev'.
141 lines
8.3 KiB
Python
141 lines
8.3 KiB
Python
"""
|
|
config.py - Environment-driven configuration for the Conflict Checker.
|
|
|
|
All knobs come from backend/.env (see .env.example). Paths are derived from this
|
|
file's location so the app runs regardless of the current working directory.
|
|
"""
|
|
|
|
import os
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
|
|
|
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# -- AI Backend (OpenRouter) ----------------------------------------
|
|
# One multimodal model does both extraction (Stage 1) and conflict
|
|
# reasoning (Stage 3). Override MODEL per-stage if you ever split them.
|
|
AI_BASE_URL = os.getenv("AI_BASE_URL", "https://openrouter.ai/api/v1")
|
|
AI_API_KEY = os.getenv("AI_API_KEY", "")
|
|
MODEL = os.getenv("MODEL", "google/gemini-2.5-pro")
|
|
# Text model when text stages run on OpenRouter (non-hybrid). Falls back to
|
|
# MODEL when unset.
|
|
TEXT_MODEL = os.getenv("TEXT_MODEL", "") or MODEL
|
|
|
|
# Agent-mode model overrides (OpenRouter IDs). Empty values inherit the
|
|
# matching general-purpose model so the skeleton requires no extra config.
|
|
AGENT_EXTRACT_MODEL = os.getenv("AGENT_EXTRACT_MODEL", "") or MODEL
|
|
AGENT_INDEX_MODEL = os.getenv("AGENT_INDEX_MODEL", "") or TEXT_MODEL
|
|
AGENT_JURISDICTION_MODEL = os.getenv("AGENT_JURISDICTION_MODEL", "") or TEXT_MODEL
|
|
AGENT_LINKER_MODEL = os.getenv("AGENT_LINKER_MODEL", "") or TEXT_MODEL
|
|
AGENT_CONFLICT_MODEL = os.getenv("AGENT_CONFLICT_MODEL", "") or MODEL
|
|
AGENT_CODE_MODEL = os.getenv("AGENT_CODE_MODEL", "") or TEXT_MODEL
|
|
AGENT_CONSTRUCT_MODEL = os.getenv("AGENT_CONSTRUCT_MODEL", "") or TEXT_MODEL
|
|
AGENT_COMPLETENESS_MODEL = os.getenv("AGENT_COMPLETENESS_MODEL", "") or TEXT_MODEL
|
|
AGENT_BRAIN_MODEL = os.getenv("AGENT_BRAIN_MODEL", "") or TEXT_MODEL
|
|
AGENT_RFI_MODEL = os.getenv("AGENT_RFI_MODEL", "") or TEXT_MODEL
|
|
|
|
# Agent-mode hard scope limits. These are intentionally independent of Classic
|
|
# batching so Agent workers can never grow into whole-set reasoning calls.
|
|
AGENT_LINK_MAX_ASSERTIONS = int(os.getenv("AGENT_LINK_MAX_ASSERTIONS", "60"))
|
|
AGENT_CLUSTER_MAX_ASSERTIONS = int(os.getenv("AGENT_CLUSTER_MAX_ASSERTIONS", "24"))
|
|
AGENT_CONFLICT_MAX_IMAGES = int(os.getenv("AGENT_CONFLICT_MAX_IMAGES", "6"))
|
|
AGENT_CODE_BATCH_SIZE = int(os.getenv("AGENT_CODE_BATCH_SIZE", "60"))
|
|
AGENT_BRAIN_MAX_TOKENS = int(os.getenv("AGENT_BRAIN_MAX_TOKENS", "16384"))
|
|
AGENT_LINK_CONCURRENCY = int(os.getenv("AGENT_LINK_CONCURRENCY", "4"))
|
|
AGENT_CONFLICT_CONCURRENCY = int(os.getenv("AGENT_CONFLICT_CONCURRENCY", "4"))
|
|
AGENT_SPECIALIST_CONCURRENCY = int(os.getenv("AGENT_SPECIALIST_CONCURRENCY", "4"))
|
|
AGENT_RFI_CONCURRENCY = int(os.getenv("AGENT_RFI_CONCURRENCY", "4"))
|
|
|
|
# Agent-mode human-review gate. When on (default), Agent runs stop after the
|
|
# Brain merge and wait for human decisions before RFIs/final report/email go
|
|
# out. AGENT_REVIEW_AUDIT_SAMPLE caps how many clean clusters get added to the
|
|
# queue as non-blocking spot-checks. REVIEW_AGGREGATE_INCLUDE_TEXT controls
|
|
# whether future cross-job review feedback aggregation may include verbatim
|
|
# source_text/images/comments (off by default = privacy-preserving).
|
|
AGENT_REQUIRE_REVIEW = os.getenv("AGENT_REQUIRE_REVIEW", "true").strip().lower() in ("1", "true", "yes")
|
|
AGENT_REVIEW_AUDIT_SAMPLE = int(os.getenv("AGENT_REVIEW_AUDIT_SAMPLE", "5"))
|
|
# NOTE: currently unwired - reserved for future cross-job aggregation tooling.
|
|
REVIEW_AGGREGATE_INCLUDE_TEXT = os.getenv("REVIEW_AGGREGATE_INCLUDE_TEXT", "false").strip().lower() in ("1", "true", "yes")
|
|
|
|
# -- Hybrid (local text LLM) ----------------------------------------
|
|
# Optional OpenAI-compatible local endpoint (e.g. a vLLM box) for the text-only
|
|
# QAQC stages. Vision stages ALWAYS use OpenRouter. The user picks hybrid per
|
|
# job in the UI; these say WHERE local is + the toggle's default state. Empty
|
|
# LOCAL_BASE_URL = hybrid disabled (falls back to OpenRouter).
|
|
LOCAL_BASE_URL = os.getenv("LOCAL_BASE_URL", "")
|
|
LOCAL_API_KEY = os.getenv("LOCAL_API_KEY", "local")
|
|
LOCAL_TEXT_MODEL = os.getenv("LOCAL_TEXT_MODEL", "")
|
|
HYBRID_DEFAULT = os.getenv("HYBRID_DEFAULT", "false").strip().lower() in ("1", "true", "yes")
|
|
|
|
# -- Pipeline -------------------------------------------------------
|
|
PDF_DPI = int(os.getenv("PDF_DPI", "100"))
|
|
MAX_PAGES = int(os.getenv("MAX_PAGES", "60"))
|
|
MAX_DIMENSION = int(os.getenv("MAX_DIMENSION", "2400")) # px cap on the long edge
|
|
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "180")) # seconds per call
|
|
EXTRACT_MAX_TOKENS = int(os.getenv("EXTRACT_MAX_TOKENS", "16384"))
|
|
REASON_MAX_TOKENS = int(os.getenv("REASON_MAX_TOKENS", "4096"))
|
|
|
|
# -- QAQC stage knobs (Stages 0-1, 3, 6-11) -------------------------
|
|
# Token caps per stage. Most are single whole-set calls, so they need more
|
|
# headroom than a per-cluster reason call.
|
|
JURISDICTION_MAX_TOKENS = int(os.getenv("JURISDICTION_MAX_TOKENS", "4096"))
|
|
SHEET_INDEX_MAX_TOKENS = int(os.getenv("SHEET_INDEX_MAX_TOKENS", "16384"))
|
|
NORMALIZE_MAX_TOKENS = int(os.getenv("NORMALIZE_MAX_TOKENS", "16384"))
|
|
QAQC_MAX_TOKENS = int(os.getenv("QAQC_MAX_TOKENS", "16384"))
|
|
CODE_MAX_TOKENS = int(os.getenv("CODE_MAX_TOKENS", "16384"))
|
|
CONSTRUCT_MAX_TOKENS = int(os.getenv("CONSTRUCT_MAX_TOKENS", "16384"))
|
|
VALIDATE_MAX_TOKENS = int(os.getenv("VALIDATE_MAX_TOKENS", "16384"))
|
|
RISK_MAX_TOKENS = int(os.getenv("RISK_MAX_TOKENS", "16384"))
|
|
RFI_MAX_TOKENS = int(os.getenv("RFI_MAX_TOKENS", "16384"))
|
|
|
|
# Stage 4 clustering engine: "llm" (semantic, fuzzy matches + catches more) or
|
|
# "deterministic" (clusterer.py, free/reproducible). Swap via env to A/B.
|
|
CLUSTERER = os.getenv("CLUSTERER", "llm").strip().lower()
|
|
CLUSTER_MAX_TOKENS = int(os.getenv("CLUSTER_MAX_TOKENS", "16384"))
|
|
CLUSTER_MAX = int(os.getenv("CLUSTER_MAX", "120"))
|
|
|
|
# Disk-backed LLM response cache for the testing loop. When on, identical calls
|
|
# (same model/prompt/images/params) replay the saved response at zero API cost,
|
|
# so re-running a set only pays for stages whose input actually changed. Off by
|
|
# default so production never serves stale results. Clear: rm -rf the dir.
|
|
LLM_CACHE = os.getenv("LLM_CACHE", "false").strip().lower() in ("1", "true", "yes")
|
|
LLM_CACHE_DIR = os.getenv("LLM_CACHE_DIR", os.path.join(_BASE_DIR, ".llm_cache"))
|
|
|
|
# Parallelism (ThreadPoolExecutor workers)
|
|
EXTRACT_CONCURRENCY = int(os.getenv("EXTRACT_CONCURRENCY", "4"))
|
|
REASON_CONCURRENCY = int(os.getenv("REASON_CONCURRENCY", "4"))
|
|
# Batched stages (normalization, per-sheet code/constructability) reuse this.
|
|
NORMALIZE_CONCURRENCY = int(os.getenv("NORMALIZE_CONCURRENCY", "4"))
|
|
CODE_CONCURRENCY = int(os.getenv("CODE_CONCURRENCY", "4"))
|
|
# Max assertions per code-review call. Code review batches sheets to keep each
|
|
# call small (avoids the truncation that zeroed out a whole-set call).
|
|
CODE_BATCH_SIZE = int(os.getenv("CODE_BATCH_SIZE", "80"))
|
|
CONSTRUCT_CONCURRENCY = int(os.getenv("CONSTRUCT_CONCURRENCY", "4"))
|
|
# Assertions per batch for the normalization stage.
|
|
NORMALIZE_BATCH_SIZE = int(os.getenv("NORMALIZE_BATCH_SIZE", "60"))
|
|
|
|
# -- App ------------------------------------------------------------
|
|
UPLOAD_DIR = os.path.join(_BASE_DIR, "uploads")
|
|
OUTPUT_DIR = os.path.join(_BASE_DIR, "outputs")
|
|
APP_TITLE = "Conflict Checker"
|
|
APP_VERSION = "0.1.0"
|
|
# Public base URL used to build the "view results" link in notification
|
|
# emails. Set to whatever address users reach this server on (e.g. the
|
|
# Tailscale/LAN URL) so the link in the email actually resolves.
|
|
APP_BASE_URL = os.getenv("APP_BASE_URL", "https://conchecker.scoutitsystems.com")
|
|
# Build identifier baked into the Docker image by CI (sha-<short_sha>, matching
|
|
# the image tag). Shown in the site header and /health. "dev" for local runs.
|
|
APP_BUILD = os.getenv("APP_BUILD", "dev")
|
|
|
|
# -- Email / SMTP (optional notification on completion) -------------
|
|
# If unset, the app still works; it just logs "SMTP not configured" and
|
|
# skips the email. Mirrors IronBid's graceful behavior.
|
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
|
SMTP_FROM = os.getenv("SMTP_FROM", "")
|
|
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
|
SMTP_USE_SSL = os.getenv("SMTP_USE_SSL", "false").lower() == "true"
|