Coverage for src / qdrant_loader_mcp_server / config.py: 76%
137 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 04:51 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 04:51 +0000
1"""Configuration settings for the RAG MCP Server."""
3import json
4import logging
5import os
6from typing import Annotated
8from dotenv import load_dotenv
9from pydantic import BaseModel, Field
11# Import reranking Pydantic model from MCP models
12from qdrant_loader_mcp_server.config_reranking import MCPReranking
14# Load environment variables from .env file
15load_dotenv()
17# Module logger
18logger = logging.getLogger(__name__)
21# --- Helpers -----------------------------------------------------------------
23# Accepted boolean truthy/falsey strings (case-insensitive)
24TRUE_VALUES = {"1", "true", "t", "yes", "y", "on"}
25FALSE_VALUES = {"0", "false", "f", "no", "n", "off"}
28def parse_bool_env(var_name: str, default: bool) -> bool:
29 """Parse a boolean from an environment variable robustly.
31 Accepted true values: 1, true, t, yes, y, on
32 Accepted false values: 0, false, f, no, n, off
34 Raises:
35 ValueError: If the variable is set but not a valid boolean value.
36 """
37 raw_value = os.getenv(var_name)
38 if raw_value is None:
39 return default
40 normalized = raw_value.strip().lower()
41 if normalized in TRUE_VALUES:
42 return True
43 if normalized in FALSE_VALUES:
44 return False
45 raise ValueError(
46 f"Invalid boolean for {var_name}: {raw_value!r}. "
47 f"Expected one of {sorted(TRUE_VALUES | FALSE_VALUES)}"
48 )
51def parse_int_env(
52 var_name: str,
53 default: int,
54 *,
55 min_value: int | None = None,
56 max_value: int | None = None,
57) -> int:
58 """Parse an integer from an environment variable with bounds checking.
60 Args:
61 var_name: Environment variable name to read.
62 default: Value to use when the variable is not set.
63 min_value: Optional lower bound (inclusive).
64 max_value: Optional upper bound (inclusive).
66 Raises:
67 ValueError: If the variable is set but not an int, or out of bounds.
68 """
69 raw_value = os.getenv(var_name)
70 if raw_value is None or raw_value.strip() == "":
71 return default
72 try:
73 value = int(raw_value)
74 except (TypeError, ValueError) as exc:
75 raise ValueError(f"Invalid integer for {var_name}: {raw_value!r}") from exc
76 if min_value is not None and value < min_value:
77 raise ValueError(f"{var_name} must be >= {min_value}; got {value}")
78 if max_value is not None and value > max_value:
79 raise ValueError(f"{var_name} must be <= {max_value}; got {value}")
80 return value
83def parse_float_env(
84 var_name: str,
85 default: float,
86 *,
87 min_value: float | None = None,
88 max_value: float | None = None,
89) -> float:
90 """Parse a float from an environment variable with bounds checking.
92 Args:
93 var_name: Environment variable name to read.
94 default: Value to use when the variable is not set.
95 min_value: Optional lower bound (inclusive).
96 max_value: Optional upper bound (inclusive).
98 Raises:
99 ValueError: If the variable is set but not a float, or out of bounds.
100 """
101 raw_value = os.getenv(var_name)
102 if raw_value is None or raw_value.strip() == "":
103 return default
104 try:
105 value = float(raw_value)
106 except (TypeError, ValueError) as exc:
107 raise ValueError(f"Invalid float for {var_name}: {raw_value!r}") from exc
108 if min_value is not None and value < min_value:
109 raise ValueError(f"{var_name} must be >= {min_value}; got {value}")
110 if max_value is not None and value > max_value:
111 raise ValueError(f"{var_name} must be <= {max_value}; got {value}")
112 return value
115class ServerConfig(BaseModel):
116 """Server configuration settings."""
118 host: str = "0.0.0.0"
119 port: int = 8000
120 log_level: str = "INFO"
123class QdrantConfig(BaseModel):
124 """Qdrant configuration settings.
126 Defaults are aligned with qdrant_loader.config.qdrant.QdrantConfig to ensure
127 consistent behavior between the loader and MCP server. The MCP server depends on
128 qdrant-loader-core (not qdrant-loader directly), so this class is kept local but
129 mirrors the same field names, types, and defaults.
130 """
132 # Aligned with qdrant_loader.config.qdrant.QdrantConfig defaults
133 url: str = "http://localhost:6333"
134 api_key: str | None = None
135 collection_name: str = "documents"
137 def __init__(self, **data):
138 """Initialize with environment variables if not provided."""
139 if "url" not in data:
140 data["url"] = os.getenv("QDRANT_URL", "http://localhost:6333")
141 if "api_key" not in data:
142 data["api_key"] = os.getenv("QDRANT_API_KEY")
143 if "collection_name" not in data:
144 data["collection_name"] = os.getenv("QDRANT_COLLECTION_NAME", "documents")
145 super().__init__(**data)
148class SearchConfig(BaseModel):
149 """Search optimization configuration settings."""
151 # Search result caching
152 cache_enabled: bool = True
153 cache_ttl: Annotated[int, Field(ge=0, le=86_400)] = 300 # 0s..24h
154 cache_max_size: Annotated[int, Field(ge=1, le=100_000)] = 500
156 # Search parameters optimization
157 hnsw_ef: Annotated[int, Field(ge=1, le=32_768)] = 128 # HNSW search parameter
158 use_exact_search: bool = False # Use exact search when needed
160 # Conflict detection performance controls (defaults calibrated for P95 ~8–10s)
161 conflict_limit_default: Annotated[int, Field(ge=2, le=50)] = 10
162 conflict_max_pairs_total: Annotated[int, Field(ge=1, le=200)] = 24
163 conflict_tier_caps: dict = {
164 "primary": 12,
165 "secondary": 8,
166 "tertiary": 4,
167 "fallback": 0,
168 }
169 conflict_use_llm: bool = True
170 conflict_max_llm_pairs: Annotated[int, Field(ge=0, le=10)] = 2
171 conflict_llm_model: str = "gpt-4o-mini"
172 conflict_llm_timeout_s: Annotated[float, Field(gt=0, le=60)] = 12.0
173 conflict_overall_timeout_s: Annotated[float, Field(gt=0, le=60)] = 9.0
174 conflict_text_window_chars: Annotated[int, Field(ge=200, le=8000)] = 2000
175 conflict_embeddings_timeout_s: Annotated[float, Field(gt=0, le=30)] = 2.0
176 conflict_embeddings_max_concurrency: Annotated[int, Field(ge=1, le=20)] = 5
178 def __init__(self, **data):
179 """Initialize with environment variables if not provided.
181 Performs robust boolean parsing and strict numeric validation to avoid
182 subtle runtime issues from malformed environment inputs.
183 """
184 if "cache_enabled" not in data:
185 data["cache_enabled"] = parse_bool_env("SEARCH_CACHE_ENABLED", True)
186 if "cache_ttl" not in data:
187 data["cache_ttl"] = parse_int_env(
188 "SEARCH_CACHE_TTL", 300, min_value=0, max_value=86_400
189 )
190 if "cache_max_size" not in data:
191 data["cache_max_size"] = parse_int_env(
192 "SEARCH_CACHE_MAX_SIZE", 500, min_value=1, max_value=100_000
193 )
194 if "hnsw_ef" not in data:
195 data["hnsw_ef"] = parse_int_env(
196 "SEARCH_HNSW_EF", 128, min_value=1, max_value=32_768
197 )
198 if "use_exact_search" not in data:
199 data["use_exact_search"] = parse_bool_env("SEARCH_USE_EXACT", False)
201 # Conflict detection env overrides (optional; safe defaults used if unset)
202 def _get_env_dict(name: str, default: dict) -> dict:
203 raw = os.getenv(name)
204 if not raw:
205 return default
206 try:
207 parsed = json.loads(raw)
208 if isinstance(parsed, dict):
209 return parsed
210 return default
211 except (json.JSONDecodeError, ValueError) as exc:
212 # Shorten raw value to avoid logging excessively large strings
213 raw_preview = raw if len(raw) <= 200 else f"{raw[:200]}..."
214 logger.warning(
215 "Failed to parse JSON for env var %s; raw=%r; falling back to default. Error: %s",
216 name,
217 raw_preview,
218 exc,
219 exc_info=True,
220 )
221 return default
223 if "conflict_limit_default" not in data:
224 data["conflict_limit_default"] = parse_int_env(
225 "SEARCH_CONFLICT_LIMIT_DEFAULT", 10, min_value=2, max_value=50
226 )
227 if "conflict_max_pairs_total" not in data:
228 data["conflict_max_pairs_total"] = parse_int_env(
229 "SEARCH_CONFLICT_MAX_PAIRS_TOTAL", 24, min_value=1, max_value=200
230 )
231 if "conflict_tier_caps" not in data:
232 data["conflict_tier_caps"] = _get_env_dict(
233 "SEARCH_CONFLICT_TIER_CAPS",
234 {"primary": 12, "secondary": 8, "tertiary": 4, "fallback": 0},
235 )
236 if "conflict_use_llm" not in data:
237 data["conflict_use_llm"] = parse_bool_env("SEARCH_CONFLICT_USE_LLM", True)
238 if "conflict_max_llm_pairs" not in data:
239 data["conflict_max_llm_pairs"] = parse_int_env(
240 "SEARCH_CONFLICT_MAX_LLM_PAIRS", 2, min_value=0, max_value=10
241 )
242 if "conflict_llm_model" not in data:
243 data["conflict_llm_model"] = os.getenv(
244 "SEARCH_CONFLICT_LLM_MODEL", "gpt-4o-mini"
245 )
246 if "conflict_llm_timeout_s" not in data:
247 data["conflict_llm_timeout_s"] = parse_float_env(
248 "SEARCH_CONFLICT_LLM_TIMEOUT_S", 12.0, min_value=1.0, max_value=60.0
249 )
250 if "conflict_overall_timeout_s" not in data:
251 data["conflict_overall_timeout_s"] = parse_float_env(
252 "SEARCH_CONFLICT_OVERALL_TIMEOUT_S", 9.0, min_value=1.0, max_value=60.0
253 )
254 if "conflict_text_window_chars" not in data:
255 data["conflict_text_window_chars"] = parse_int_env(
256 "SEARCH_CONFLICT_TEXT_WINDOW_CHARS", 2000, min_value=200, max_value=8000
257 )
258 if "conflict_embeddings_timeout_s" not in data:
259 data["conflict_embeddings_timeout_s"] = parse_float_env(
260 "SEARCH_CONFLICT_EMBEDDINGS_TIMEOUT_S",
261 2.0,
262 min_value=1.0,
263 max_value=30.0,
264 )
265 if "conflict_embeddings_max_concurrency" not in data:
266 data["conflict_embeddings_max_concurrency"] = parse_int_env(
267 "SEARCH_CONFLICT_EMBEDDINGS_MAX_CONCURRENCY",
268 5,
269 min_value=1,
270 max_value=20,
271 )
272 super().__init__(**data)
275class OpenAIConfig(BaseModel):
276 """OpenAI configuration settings."""
278 # Optional to avoid startup crashes when OPENAI_API_KEY is not yet set;
279 # downstream callers are expected to validate presence before use.
280 api_key: str | None = None
281 model: str = "text-embedding-3-small"
282 chat_model: str = "gpt-3.5-turbo"
285class Config(BaseModel):
286 """Main configuration class.
288 Note: QdrantConfig defaults are aligned with qdrant_loader.config.qdrant.QdrantConfig
289 to ensure consistent behavior between the loader and MCP server. The MCP server
290 cannot import from qdrant-loader directly (it only depends on qdrant-loader-core),
291 so alignment is maintained by convention rather than import.
292 """
294 server: ServerConfig = Field(default_factory=ServerConfig)
295 qdrant: QdrantConfig = Field(default_factory=QdrantConfig)
296 openai: OpenAIConfig = Field(
297 default_factory=lambda: OpenAIConfig(api_key=os.getenv("OPENAI_API_KEY"))
298 )
299 search: SearchConfig = Field(default_factory=SearchConfig)
300 # Reranking configuration (loaded from global.reranking in config.yaml)
301 reranking: MCPReranking = Field(default_factory=MCPReranking)