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

1"""Configuration settings for the RAG MCP Server.""" 

2 

3import json 

4import logging 

5import os 

6from typing import Annotated 

7 

8from dotenv import load_dotenv 

9from pydantic import BaseModel, Field 

10 

11# Import reranking Pydantic model from MCP models 

12from qdrant_loader_mcp_server.config_reranking import MCPReranking 

13 

14# Load environment variables from .env file 

15load_dotenv() 

16 

17# Module logger 

18logger = logging.getLogger(__name__) 

19 

20 

21# --- Helpers ----------------------------------------------------------------- 

22 

23# Accepted boolean truthy/falsey strings (case-insensitive) 

24TRUE_VALUES = {"1", "true", "t", "yes", "y", "on"} 

25FALSE_VALUES = {"0", "false", "f", "no", "n", "off"} 

26 

27 

28def parse_bool_env(var_name: str, default: bool) -> bool: 

29 """Parse a boolean from an environment variable robustly. 

30 

31 Accepted true values: 1, true, t, yes, y, on 

32 Accepted false values: 0, false, f, no, n, off 

33 

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 ) 

49 

50 

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. 

59 

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). 

65 

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 

81 

82 

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. 

91 

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). 

97 

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 

113 

114 

115class ServerConfig(BaseModel): 

116 """Server configuration settings.""" 

117 

118 host: str = "0.0.0.0" 

119 port: int = 8000 

120 log_level: str = "INFO" 

121 

122 

123class QdrantConfig(BaseModel): 

124 """Qdrant configuration settings. 

125 

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 """ 

131 

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" 

136 

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) 

146 

147 

148class SearchConfig(BaseModel): 

149 """Search optimization configuration settings.""" 

150 

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 

155 

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 

159 

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 

177 

178 def __init__(self, **data): 

179 """Initialize with environment variables if not provided. 

180 

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) 

200 

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 

222 

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) 

273 

274 

275class OpenAIConfig(BaseModel): 

276 """OpenAI configuration settings.""" 

277 

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" 

283 

284 

285class Config(BaseModel): 

286 """Main configuration class. 

287 

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 """ 

293 

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)