Job 98194fa8d215 showed every extract call hitting the 32k cap with only ~20k chars visible despite reasoning effort=low - Gemini 2.5 Pro still burned ~25k thinking tokens per sheet. - EXTRACT_MAX_TOKENS default 32768 -> 65536 (model output ceiling) - new EXTRACT_REASONING_MAX_TOKENS (default 2048): OpenRouter reasoning max_tokens / Gemini thinking_budget; takes precedence over effort - log per-call reasoning token counts (usage.completion_tokens_details) and include thinking count in the finish_reason=length marker
163 lines
9.9 KiB
Python
163 lines
9.9 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
|
|
# Gemini 2.5 Pro counts thinking tokens against max_tokens, so the visible
|
|
# JSON budget is well under this number on dense sheets. 65536 is the model's
|
|
# output ceiling - give thinking all the room it wants so visible JSON never
|
|
# truncates; the thinking budget itself is capped separately below.
|
|
EXTRACT_MAX_TOKENS = int(os.getenv("EXTRACT_MAX_TOKENS", "65536"))
|
|
# Reasoning effort for the per-sheet extractor (OpenRouter reasoning knob).
|
|
# Extraction is perceptive, not deliberative - "low" keeps thinking tokens
|
|
# from eating the output budget. Empty string disables the parameter.
|
|
EXTRACT_REASONING_EFFORT = os.getenv("EXTRACT_REASONING_EFFORT", "low").strip()
|
|
# Hard thinking-token budget for the extractor (OpenRouter reasoning
|
|
# max_tokens -> Gemini thinking_budget). "low" effort alone still let Gemini
|
|
# burn ~25k thinking tokens per sheet (job 98194fa8d215); a hard cap forces
|
|
# the budget into visible output. 0 disables -> falls back to the effort knob.
|
|
# Mutually exclusive with effort when set (OpenRouter rejects both together).
|
|
EXTRACT_REASONING_MAX_TOKENS = int(os.getenv("EXTRACT_REASONING_MAX_TOKENS", "2048"))
|
|
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"))
|
|
|
|
# Verbose LLM observability. Per call, one line lands in the job log (model,
|
|
# backend, prompt size, response size, parsed-item counts, per-call cost) and
|
|
# the full request/response is dumped to <out_dir>/llm_raw/ (base64 image
|
|
# payloads excluded; image count recorded instead) so missed or hallucinated
|
|
# items can be traced back to exactly what the model saw and returned.
|
|
LLM_VERBOSE = os.getenv("LLM_VERBOSE", "true").strip().lower() in ("1", "true", "yes")
|
|
LLM_RAW_DUMP = os.getenv("LLM_RAW_DUMP", "true").strip().lower() in ("1", "true", "yes")
|
|
|
|
# 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"
|