import os # --------------------------------------------------------------------------- # Shared (both approaches) # --------------------------------------------------------------------------- # Page rendering (embedding/parsing at index time, and the answering model). RENDER_DPI = 150 # Answering model (runs locally on ZeroGPU). Revisions are pinned because all # models here load trust_remote_code; bump deliberately after reviewing # upstream changes. MINICPM_MODEL_ID = os.environ.get("MINICPM_MODEL_ID", "openbmb/MiniCPM-V-4_5") MINICPM_REVISION = os.environ.get( "MINICPM_REVISION", "fd3209b2e0580e346fc33d2c6f85b6e9332eecda" ) ANSWER_MAX_NEW_TOKENS = 2048 # Let MiniCPM-V reason (legend → callout-number → leader-line → part) before # committing to a grounding box for "circle the ". This is only the # DEFAULT — the UI settings panel sends a per-request override (see api_find's # `think`). Off by default: it roughly multiplies grounding latency (64 → ~512 # generated tokens) and mainly helps exploded-diagram callouts, not the # dense-table wrong-row misses. GROUND_ENABLE_THINKING = os.environ.get("GROUND_ENABLE_THINKING", "").lower() in ( "1", "true", "yes", ) # Token budgets for one grounding generation. A bare fits in 64; a think # trace does not (it gets cut off before the box), so the budget tracks whether # thinking is on for that call. GROUND_BOX_MAX_NEW_TOKENS = 64 GROUND_THINK_MAX_NEW_TOKENS = 512 # Agent brain: MiniCPM5-1B — a standard LlamaForCausalLM (no trust_remote_code), # 131k context. The small TEXT model that drives the find-and-point loop: each # step it picks ONE tool from the conversation so far, the manual's table of # contents, and the whole text of the page being viewed. The MiniCPM-V VLM above # stays the "eyes" (grounding the circle + the ingest figure/table descriptions); # this is the "brain". Revision is left unpinned (standard architecture, no # remote code to drift) but overridable; pin a commit before a real deploy. MINICPM_AGENT_MODEL_ID = os.environ.get("MINICPM_AGENT_MODEL_ID", "openbmb/MiniCPM5-1B") MINICPM_AGENT_REVISION = os.environ.get("MINICPM_AGENT_REVISION", "") or None # Selectable agent brains, offered in the UI settings panel. ONE model is meant # to be resident in VRAM at a time — switching evicts the previous and loads the # next (models/minicpm_agent.use_model). The FIRST/default brain is loaded at # import (ZeroGPU's startup phase) and "packed" into the forked GPU worker, so it # stays resident for the whole process and the common (no-switch) turn pays NO # per-turn load cost. A brain SWITCHED IN at runtime is built inside the GPU # window instead (not packed), so its first turn after a switch is slower. # # The default is MiniCPM4.1-8B in bf16 (~16 GiB). It fits because the ColEmbed # visual retriever (~8 GiB) is NO LONGER packed at import — it lazy-loads only # when the "visual" search index is used (models/colembed.py). So the resident set # is the MiniCPM-V "eyes" + the Nemotron text embedder + this 8B brain, which # leaves room for the grounding spike on the 48 GiB slice. Because the 8B and # ColEmbed cannot BOTH be resident, the 8B sets forbid_visual: a turn that asks for # visual search while it is active is served by the parsed index instead (enforced # in app.py). Smaller brains leave room for ColEmbed to lazy-load, so they keep # visual search. # # Each loads as an AutoModelForCausalLM; `trust_remote_code` (default False) flags # the ones that ship custom modeling code (MiniCPM3 / MiniCPM4.1). `thinking` flags # whether the chat template accepts enable_thinking (Qwen3, MiniCPM5, MiniCPM4.1 do # — tool routing passes it False; MiniCPM3 does not). The FIRST entry is the # default at boot; the minicpm5-1b entry stays selectable and still tracks the # MINICPM_AGENT_MODEL_ID/REVISION env overrides. Only one brain is resident at a time. AGENT_MODELS = [ { "key": "minicpm4.1-8b", "label": "MiniCPM4.1 8B", "model_id": "openbmb/MiniCPM4.1-8B", # DEFAULT brain. Hybrid-reasoning 8B in bf16 (~16 GiB) — the best eval # config (0.90 tool / 0.90 args, and it unlocks the v3 coincidence fix). # Loaded at import so ZeroGPU packs it (no per-turn reload) and it decodes # in bf16 (faster than a bnb-int8 build). trust_remote_code custom modeling # (sparse "InfLLM v2" attention); runs clean on current transformers, unlike # MiniCPM3-4B — pin a reviewed commit (revision) before a real deploy. "revision": None, "thinking": True, "trust_remote_code": True, # Too large to keep the ColEmbed visual retriever resident alongside it, so # visual search is disabled while this brain is active (falls back to parsed). "forbid_visual": True, }, { "key": "minicpm5-1b", "label": "MiniCPM5 1B", "model_id": MINICPM_AGENT_MODEL_ID, "revision": MINICPM_AGENT_REVISION, "thinking": True, }, { "key": "qwen3-1.7b", "label": "Qwen3 1.7B", "model_id": "Qwen/Qwen3-1.7B", "revision": None, "thinking": True, }, { "key": "qwen3-0.6b", "label": "Qwen3 0.6B", "model_id": "Qwen/Qwen3-0.6B", "revision": None, "thinking": True, }, { "key": "qwen3-4b", "label": "Qwen3 4B", "model_id": "Qwen/Qwen3-4B", "revision": None, # Native Qwen3 arch (no trust_remote_code → immune to the custom-modeling # rot that breaks MiniCPM3-4B on current transformers). ~4B / ~8.1 GiB bf16, # same footprint class as MiniCPM3-4B, which was VRAM-vetted to fit; routing # passes enable_thinking=False. "thinking": True, }, { "key": "minicpm3-4b", "label": "MiniCPM3 4B", "model_id": "openbmb/MiniCPM3-4B", # trust_remote_code model — pin a reviewed commit before a real deploy # (see use_model). Left unpinned here so the entry tracks latest. "revision": None, # MiniCPM3 has no hybrid-reasoning mode; its chat template doesn't take # enable_thinking, so leave it off (the kwarg is then omitted). "thinking": False, "trust_remote_code": True, # ~4B / ~8.6 GiB in bf16: with the resident VLM+ColEmbed+embedder (~28 GiB) # and the un-evictable default brain, it loads into the ~15.6 GiB free at # switch time with ~5 GiB to spare after the grounding spike. The 8B (16 # GiB) didn't fit — see core/vram.py / the find-turn VRAM logs. }, ] DEFAULT_AGENT_MODEL = AGENT_MODELS[0]["key"] # A tool-call decision is short JSON; a rerank reply is a single number. 96 was # too tight — the 1B writes verbose search queries and was getting CUT OFF # mid-string (unterminated JSON → parse fail → wasted retry), seen live. AGENT_MAX_NEW_TOKENS = 128 # Backstop on tool steps within one turn, so a confused loop can't run forever. AGENT_MAX_STEPS = 6 # ColEmbed shortlist size the search tool retrieves (the eval default). AGENT_SEARCH_CANDIDATES = 5 # How many of the search shortlist's top pages get their FULL TEXT fed back after # a search, for the brain to RERANK and recover over — it circles the target on # whichever candidate actually has it, not just retrieval's #1 (the recovery path # when the right page is rank 2/3). The rest of the shortlist still shows as # thumbnails. Capped low because each page's text is large; raising it (or the k # slider) deepens the recovery pool at the cost of context/latency. The smarter 8B # reranks reliably where the old 1B (rerank 0.68 < top-1 0.84) could not. AGENT_RERANK_CANDIDATES = 3 # Which index the search tool ranks against. Both retrievers return the same # (doc, page, score) shape, so the agent loop is identical either way: # visual — ColEmbed late-interaction MaxSim over page-image embeddings # parsed — Nemotron dense cosine over parsed section/figure/table chunks # Exposed as a UI setting (settings panel); the parsed index wins on spec/table # lookups, so it is the default for now. RETRIEVAL_MODES = [ {"key": "parsed", "label": "Parsed (text)"}, {"key": "visual", "label": "Visual (ColEmbed)"}, ] DEFAULT_RETRIEVAL_MODE = RETRIEVAL_MODES[0]["key"] # Past turns of conversation fed back as memory (resolve "the other one", "go # back"); the live turn carries the full current page text (no table of contents). AGENT_HISTORY_TURNS = 6 # One ZeroGPU call covers the whole question: query embedding + retrieval + # page rendering + answer generation. ASK_GPU_DURATION = 120 # Pages handed to the answering model. DEFAULT_TOP_K = 3 MAX_TOP_K = 5 # --------------------------------------------------------------------------- # Find-and-point (pipelines/agent_ask.py) — every non-obvious request is one # GPU turn: the 1B agent loops over tools (search / go_to_page / circle) against # the current page's text — no table of contents is injected. # The frontend's breadcrumb section nav is client-side and never reaches the GPU. # --------------------------------------------------------------------------- # One ZeroGPU call covers a whole agent turn: up to AGENT_MAX_STEPS tool-choice # generations, ColEmbed retrieval + page rendering and the 1B rerank inside a # search step, and one MiniCPM-V grounding generation for a circle. FIND_GPU_DURATION = 180 # --------------------------------------------------------------------------- # Visual approach: ColEmbed late-interaction page embeddings + MaxSim # --------------------------------------------------------------------------- COLEMBED_MODEL_ID = os.environ.get( "COLEMBED_MODEL_ID", "nvidia/nemotron-colembed-vl-4b-v2" ) COLEMBED_REVISION = os.environ.get( "COLEMBED_REVISION", "0ed152d91f8ad4c5d48296b51c220f686641a398" ) # sdpa works for this model and needs no extra wheels on ZeroGPU; set # COLEMBED_ATTN=flash_attention_2 if flash-attn is installed. COLEMBED_ATTN = os.environ.get("COLEMBED_ATTN", "sdpa") # Indexing: pages embedded per GPU call, and model batch size within a call. EMBED_PAGES_PER_CALL = 64 EMBED_BATCH_SIZE = 8 EMBED_GPU_DURATION = 240 # Retrieval: MaxSim is computed on GPU over fixed-size batches of pages # streamed from the on-disk store. SCORE_PAGES_PER_BATCH = 32 # --------------------------------------------------------------------------- # Parsed approach: Nemotron Parse -> MiniCPM figure/table descriptions -> # section chunks -> dense embeddings + cosine retrieval # --------------------------------------------------------------------------- # Nemotron Parse requires transformers==5.6.1, which is incompatible with # ColEmbed/MiniCPM (<5). It therefore runs only in the dedicated parse stage # on Modal (scripts/index_modal.py) and is never imported on the Space. NEMOTRON_PARSE_MODEL_ID = os.environ.get( "NEMOTRON_PARSE_MODEL_ID", "nvidia/NVIDIA-Nemotron-Parse-v1.2" ) NEMOTRON_PARSE_REVISION = os.environ.get( "NEMOTRON_PARSE_REVISION", "2bd0189bffd6cdded6280d9f22a4077b25a504e3" ) # Pages parsed per generate call at index time (identical task prompt per # page, so the batch needs no padding). PARSE_BATCH_SIZE = 16 # Dense bi-encoder for chunk/query embeddings (2048-dim, mean-pooled). # Its remote code supports transformers 4.56+ — same env as the Space. NEMOTRON_EMBED_MODEL_ID = os.environ.get( "NEMOTRON_EMBED_MODEL_ID", "nvidia/llama-nemotron-embed-vl-1b-v2" ) NEMOTRON_EMBED_REVISION = os.environ.get( "NEMOTRON_EMBED_REVISION", "0c6f636ed4c022e427277c4c336054d6cdffaa87" ) EMBED_TEXT_MAX_LENGTH = 8192 # processor token budget for text-only inputs EMBED_TEXT_BATCH_SIZE = 8 # MiniCPM descriptions generated at ingest for Picture/Table elements. # Each description is conditioned on document context (manual name, section # heading, adjacent caption, page text) so it uses the manual's terminology; # the page-text part of that context is capped at this many characters. DESCRIBE_MAX_NEW_TOKENS = 256 DESCRIBE_CONTEXT_MAX_CHARS = 1200 # Figure/table descriptions generated per batched MiniCPM chat() call. DESCRIBE_BATCH_SIZE = 16 # Picture bboxes with either side smaller than this (pixels at RENDER_DPI) # are skipped — icons, bullets, print artifacts. FIGURE_MIN_SIDE_PX = 40 # Section chunking (core/chunking.py): a Title/Section-header closes the # current section unless it is still under SECTION_MIN_CHARS (sparse pages # merge forward into the next section); sections over SECTION_MAX_CHARS split # at element boundaries with the heading repeated. SECTION_MIN_CHARS = 200 SECTION_MAX_CHARS = 6000 # Retrieval: chunk candidates scored by cosine before the page budget # (top_k pages) is applied via parent-document lookup. PARSED_TOP_CHUNKS = 8 # --------------------------------------------------------------------------- # Stores # --------------------------------------------------------------------------- # HF Spaces persistent storage mounts at /data; fall back to a local directory # for development. The library dataset mirrors PREINDEXED_DIR: one top-level # directory per indexing method, one doc directory per manual under it. _DATA_ROOT = os.environ.get("DATA_ROOT") or ( "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.dirname(__file__)), "data") ) PREINDEXED_DIR = os.path.join(_DATA_ROOT, "preindexed") VISUAL_SUBDIR = "visual" PARSED_SUBDIR = "parsed" LIBRARY_DATASET_ID = os.environ.get( "LIBRARY_DATASET_ID", "build-small-hackathon/repair-guy-library" ) # --------------------------------------------------------------------------- # Local mock mode (UI iteration with no GPU / model downloads / HF sync) # --------------------------------------------------------------------------- # With MOCK_MODELS set, app.py serves canned answers grounded in real, rendered # pages of any PDF dropped into MOCK_PDF_DIR. Nothing on the mock path imports # torch / spaces / the model modules (which load CUDA at import), so the Gradio # UI boots instantly on a laptop. See pipelines/mock_ask.py. MOCK_MODELS = os.environ.get("MOCK_MODELS", "").lower() in ("1", "true", "yes") MOCK_PDF_DIR = os.environ.get("MOCK_PDF_DIR") or os.path.join(_DATA_ROOT, "mock_pdfs")