DavidL72Code/UMB_Sustainable_Chatbot
0
1"""Configuration and the small shared types.2 3Split out of Chatbot.py unchanged. Every other module reads settings from here,4so it must not import any of them back — keep this file free of pipeline5imports or the dependency graph turns into a cycle.6 7PROJECT_ROOT stays correct after the move because this module sits beside8Chatbot.py at the repository root; both resolve to the same directory. Moving9either into a subpackage would break the 27 call sites that hang off it.10"""11from __future__ import annotations12 13import os14from pathlib import Path15from typing import Callable16 17 18LLMCallable = Callable[[str], str]19 20 21 22class ChatbotConfig:23 collection_name: str = "docs"24 persist_directory: str = "./chroma_db"25 seed_documents_directory: str = os.getenv("SEED_DOCUMENTS_DIRECTORY", "./SEED_DOCUMENTS")26 force_reindex: bool = os.getenv("FORCE_REINDEX", "").lower() in {"1", "true", "yes"}27 embedding_model_name: str = os.getenv("EMBEDDING_MODEL", "BAAI/bge-base-en-v1.5")28 # bge is asymmetric: passages are embedded bare, queries need this prefix.29 # Omitting it measurably degrades retrieval quality.30 query_embedding_prefix: str = os.getenv(31 "QUERY_EMBEDDING_PREFIX",32 "Represent this sentence for searching relevant passages: ",33 )34 chunk_size: int = 51235 chunk_overlap: int = 5036 summary_chunk_size: int = 140037 summary_chunk_overlap: int = 14038 # 5 meant only the top 5 reranked chunks seeded the context. Measured on the39 # 2026-08-29 set, 12 put the answer-bearing document in front of the model40 # far more often, on both previously-failing and previously-passing questions.41 top_k: int = 1042 retrieval_candidate_pool: int = 1243 document_neighbor_count: int = int(os.getenv("DOCUMENT_NEIGHBOR_COUNT", "2"))44 document_neighbor_limit: int = int(os.getenv("DOCUMENT_NEIGHBOR_LIMIT", "8"))45 recent_history_turns: int = int(os.getenv("RECENT_HISTORY_TURNS", "6"))46 # Off by default, deliberately. This read "1" for a long time, but every47 # planner call was failing on a 400 the caller swallowed, so the effective48 # behaviour was off and both 208-question benchmarks were measured that way.49 # Repairing the call (see _MODELS_WITHOUT_THINKING) made the planner50 # authoritative again and changed 5 of 20 spot-check answers, two of them51 # regressions: entity questions rerouted from the entity registry to the52 # document registry and started listing filenames instead of people.53 # Leave this off until a full 208 run with it on beats the current baseline.54 always_llm_query_planning: bool = os.getenv("ALWAYS_LLM_QUERY_PLANNING", "0").lower() in {"1", "true", "yes"}55 gemini_api_key: str = os.getenv("GEMINI_API_KEY", "")56 gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-3.5-flash-lite")57 rewrite_model: str = os.getenv("REWRITE_MODEL", "gemma-4-26b-a4b-it")58 # Default to greedy decoding. At 0.7 the same question returned a different59 # answer on every run, which made pipeline behaviour unobservable: a fix and60 # a coin flip looked identical. With this at 0.0, _gemini_gen_config pins61 # top_k=1/top_p=1.0 and a fixed seed, so a given question and evidence62 # produce one answer. Set GEMINI_TEMPERATURE=0.7 to restore varied phrasing.63 gemini_temperature: float = float(os.getenv("GEMINI_TEMPERATURE", "0.0"))64 web_host: str = os.getenv("CHATBOT_HOST", "0.0.0.0")65 web_port: int = int(os.getenv("PORT", os.getenv("CHATBOT_PORT", "7860")))66 cors_origins: str = os.getenv("CORS_ORIGINS", "")67 trust_proxy_headers: bool = os.getenv("TRUST_PROXY_HEADERS", "0").lower() in {"1", "true", "yes"}68 dashboard_trace_mode: str = os.getenv("DASHBOARD_TRACE_MODE", "staff").strip().lower()69 admin_username: str = os.getenv("ADMIN_USERNAME", "").strip()70 admin_password_hash: str = os.getenv("ADMIN_PASSWORD_HASH", "").strip()71 dashboard_session_secret: str = os.getenv("DASHBOARD_SESSION_SECRET", "").strip()72 admin_users_json: str = os.getenv("ADMIN_USERS_JSON", "").strip()73 evidence_selection: bool = os.getenv("EVIDENCE_SELECTION", "1").lower() in {"1", "true", "yes"}74 # Cross-encoder reranking. Off by default: measured on this corpus it costs75 # 60s per question with BAAI/bge-reranker-base (44s at max_length=256) and76 # 7.6s with ms-marco-MiniLM-L-6-v2, against a pool of ~66 chunks on CPU.77 # MPS was slower than CPU at this batch size. Enable only with a GPU or a78 # smaller candidate pool.79 cross_encoder_rerank: bool = os.getenv("CROSS_ENCODER_RERANK", "0").lower() in {"1", "true", "yes"}80 cross_encoder_model: str = os.getenv("CROSS_ENCODER_MODEL", "BAAI/bge-reranker-base").strip()81 cross_encoder_max_length: int = int(os.getenv("CROSS_ENCODER_MAX_LENGTH", "512"))82 cross_encoder_top_n: int = int(os.getenv("CROSS_ENCODER_TOP_N", "40"))83 evidence_selection_model: str = os.getenv("EVIDENCE_SELECTION_MODEL", "").strip()84 # Answers scoring below this retrieval strength are kept for staff review.85 # Set FLAG_MIN_SCORE_GAP above 0 to also flag near-tied top chunks.86 flag_min_top_score: float = float(os.getenv("FLAG_MIN_TOP_SCORE", "0.90"))87 flag_min_score_gap: float = float(os.getenv("FLAG_MIN_SCORE_GAP", "0"))88 # With Supabase configured the local JSONL is redundant, and on an89 # ephemeral Space it is lost on restart anyway. Keep it for local dev.90 chat_log_to_file: bool = os.getenv("CHAT_LOG_TO_FILE", "").lower() in {"1", "true", "yes"}91 debug_mode: bool = os.getenv("FLASK_DEBUG", "0") == "1"92 chat_rate_limit_count: int = int(os.getenv("CHAT_RATE_LIMIT_COUNT", "10"))93 chat_rate_limit_window_seconds: int = int(os.getenv("CHAT_RATE_LIMIT_WINDOW_SECONDS", "60"))94 suggestions_rate_limit_count: int = int(os.getenv("SUGGESTIONS_RATE_LIMIT_COUNT", "30"))95 suggestions_rate_limit_window_seconds: int = int(os.getenv("SUGGESTIONS_RATE_LIMIT_WINDOW_SECONDS", "60"))96 suggestions_verify_retrieval: bool = os.getenv("SUGGESTIONS_VERIFY_RETRIEVAL", "0").lower() in {"1", "true", "yes"}97 conversation_ttl_seconds: int = int(os.getenv("CONVERSATION_TTL_SECONDS", "3600"))98 99 100class SourceDocument(dict):101 pass102 103 104class ConversationTurn(dict):105 pass106 107 108PROJECT_ROOT = Path(__file__).resolve().parent109CHAT_LOG_PATH = PROJECT_ROOT / "logs" / "chat_events.jsonl"110 