Coverage for src/local_deep_research/research_library/services/rag_service_factory.py: 93%

71 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2RAG Service Factory 

3 

4Provides get_rag_service() for creating LibraryRAGService instances 

5with appropriate settings. Extracted from rag_routes.py to avoid 

6circular imports (service → routes). 

7""" 

8 

9import json 

10from typing import Optional 

11 

12from loguru import logger 

13 

14from ...constants import ( 

15 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

16 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

17 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

18 DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

19 DEFAULT_LOCAL_SEARCH_MODEL, 

20 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

21 DEFAULT_LOCAL_SEARCH_PROVIDER, 

22 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

23 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS, 

24 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

25) 

26from ...database.models.library import Collection 

27from ...database.session_context import get_user_db_session 

28from ...utilities.db_utils import get_settings_manager 

29from ...utilities.type_utils import to_bool 

30from ..services.library_rag_service import ( 

31 LibraryRAGService, 

32 resolve_collection_index_settings, 

33) 

34 

35 

36def _get_default_text_separators(settings): 

37 """Return configured default text separators, parsing string values if needed.""" 

38 default_text_separators = settings.get_setting( 

39 "local_search_text_separators", 

40 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

41 ) 

42 if isinstance(default_text_separators, str): 

43 # A value that is not valid JSON (e.g. a not-yet-migrated corrupt row) 

44 # falls back to the default separators — migration #4298 heals existing 

45 # corrupt data. 

46 try: 

47 default_text_separators = json.loads(default_text_separators) 

48 except json.JSONDecodeError: 

49 logger.warning( 

50 "Invalid JSON for local_search_text_separators: {!r} — using default separators", 

51 default_text_separators, 

52 ) 

53 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

54 

55 if not isinstance(default_text_separators, list): 

56 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

57 

58 return default_text_separators 

59 

60 

61def _enforce_embeddings_policy( 

62 embedding_provider: str, settings_manager, username: str 

63) -> None: 

64 """Pre-flight egress-policy check before constructing the RAG service. 

65 

66 Fails BEFORE the first chunk is processed (per the plan's pre-flight 

67 requirement) — important because indexing a large corpus can take 10+ 

68 minutes; we want the user to see a clear policy error immediately, not 

69 after embeddings have already been generated for hundreds of chunks. 

70 

71 No-op when the egress policy does not require local embeddings — 

72 i.e. when ``embeddings.require_local`` is False AND the scope does not 

73 imply it. Under PRIVATE_ONLY the requirement is forced regardless of 

74 the flag (see context_from_snapshot). 

75 """ 

76 # Lazy import to avoid pulling the security module on every factory call. 

77 from ...security.egress.policy import ( 

78 DEFAULT_EGRESS_SCOPE, 

79 Decision, 

80 PolicyDeniedError, 

81 context_from_snapshot, 

82 evaluate_embeddings, 

83 ) 

84 

85 # We don't have a full settings snapshot here, only a SettingsManager. 

86 # Build a minimal snapshot for the policy module — only the keys it 

87 # reads for scope coupling + embedding classification matter. 

88 scope = ( 

89 settings_manager.get_setting("policy.egress_scope") 

90 or DEFAULT_EGRESS_SCOPE 

91 ) 

92 require_local_flag = to_bool( 

93 settings_manager.get_setting("embeddings.require_local", False), False 

94 ) 

95 base_url = settings_manager.get_setting("embeddings.openai.base_url") 

96 ollama_url = settings_manager.get_setting("embeddings.ollama.url") 

97 # evaluate_embeddings() classifies the ollama embeddings endpoint from 

98 # embeddings.ollama.url OR, when that's unset, llm.ollama.url. Omitting 

99 # the llm.* fallback here would misclassify a user who only configured 

100 # the shared llm.ollama.url as "remote" and wrongly deny local ollama 

101 # embeddings. Populate both so the classification matches runtime. 

102 llm_ollama_url = settings_manager.get_setting("llm.ollama.url") 

103 snapshot = { 

104 "policy.egress_scope": scope, 

105 "embeddings.require_local": require_local_flag, 

106 "embeddings.openai.base_url": base_url or "", 

107 "embeddings.ollama.url": ollama_url or "", 

108 "llm.ollama.url": llm_ollama_url or "", 

109 } 

110 # Build the ctx from the ACTUAL scope so PRIVATE_ONLY forces local 

111 # embeddings even when the raw flag is False. primary_engine="library" 

112 # is concrete. 

113 try: 

114 ctx = context_from_snapshot(snapshot, "library", username=username) 

115 except PolicyDeniedError: 

116 raise 

117 except ValueError as exc: 

118 raise PolicyDeniedError( 

119 Decision(False, "invalid_policy_config"), 

120 target=embedding_provider, 

121 ) from exc 

122 # No-op unless the (scope-aware) policy requires local embeddings. 

123 if not ctx.require_local_embeddings: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 return 

125 

126 decision = evaluate_embeddings( 

127 embedding_provider, ctx, settings_snapshot=snapshot 

128 ) 

129 if not decision.allowed: 

130 logger.bind(policy_audit=True).warning( 

131 "embeddings provider denied by egress policy", 

132 provider=embedding_provider, 

133 reason=decision.reason, 

134 ) 

135 raise PolicyDeniedError(decision, target=embedding_provider) 

136 

137 

138def get_rag_service( 

139 username: str, 

140 collection_id: Optional[str] = None, 

141 use_defaults: bool = False, 

142 db_password: Optional[str] = None, 

143) -> LibraryRAGService: 

144 """ 

145 Get RAG service instance with appropriate settings. 

146 

147 Args: 

148 username: Username for database access and settings lookup 

149 collection_id: Optional collection UUID to load stored settings from 

150 use_defaults: When True, ignore stored collection settings and use 

151 current defaults. Pass True on force-reindex so that the new 

152 default embedding model is picked up. 

153 db_password: Optional database password for encrypted databases 

154 

155 If collection_id is provided: 

156 - Uses collection's stored settings if they exist (unless use_defaults=True) 

157 - Uses current defaults for new collections (and stores them) 

158 

159 If no collection_id: 

160 - Uses current default settings 

161 """ 

162 # Use get_user_db_session so that settings are readable from background 

163 # threads (no Flask app context). Without an explicit db_session, 

164 # get_settings_manager falls back to JSON defaults only, and the 

165 # local_search_* keys have no JSON defaults — causing user-configured 

166 # embedding settings to be silently ignored. See #3453. 

167 with get_user_db_session(username, db_password) as db_session: 

168 settings = get_settings_manager( 

169 db_session=db_session, username=username 

170 ) 

171 

172 # Get current default settings. 

173 # Explicit fallbacks to centralized constants ensure safe operation 

174 # on fresh installs before settings are saved to the database. 

175 raw_embedding_model = settings.get_setting( 

176 "local_search_embedding_model" 

177 ) 

178 raw_embedding_provider = settings.get_setting( 

179 "local_search_embedding_provider" 

180 ) 

181 # Warn on silent fallback so a regression of #3453 is visible in logs 

182 # instead of being masked by fallback defaults. On fresh installs 

183 # this fires legitimately until the user saves settings; in a 

184 # regression it would fire on every indexing call. 

185 if not raw_embedding_model and not raw_embedding_provider: 

186 logger.warning( 

187 "local_search embedding settings are empty; falling back to " 

188 f"defaults ({DEFAULT_LOCAL_SEARCH_PROVIDER}/{DEFAULT_LOCAL_SEARCH_MODEL}). " 

189 "Expected on fresh installs before settings are saved; " 

190 "otherwise check that db_session is being passed to " 

191 "SettingsManager (see #3453)." 

192 ) 

193 default_embedding_model = ( 

194 raw_embedding_model or DEFAULT_LOCAL_SEARCH_MODEL 

195 ) 

196 default_embedding_provider = ( 

197 raw_embedding_provider or DEFAULT_LOCAL_SEARCH_PROVIDER 

198 ) 

199 raw_chunk_size = settings.get_setting("local_search_chunk_size") 

200 default_chunk_size = ( 

201 int(raw_chunk_size) 

202 if raw_chunk_size is not None and str(raw_chunk_size).strip() != "" 

203 else DEFAULT_LOCAL_SEARCH_CHUNK_SIZE 

204 ) 

205 raw_chunk_overlap = settings.get_setting("local_search_chunk_overlap") 

206 default_chunk_overlap = ( 

207 int(raw_chunk_overlap) 

208 if raw_chunk_overlap is not None 

209 and str(raw_chunk_overlap).strip() != "" 

210 else DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP 

211 ) 

212 default_splitter_type = ( 

213 settings.get_setting("local_search_splitter_type") 

214 or DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE 

215 ) 

216 default_text_separators = _get_default_text_separators(settings) 

217 default_distance_metric = ( 

218 settings.get_setting("local_search_distance_metric") 

219 or DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC 

220 ) 

221 default_normalize_vectors = to_bool( 

222 settings.get_setting("local_search_normalize_vectors"), 

223 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

224 ) 

225 default_index_type = ( 

226 settings.get_setting("local_search_index_type") 

227 or DEFAULT_LOCAL_SEARCH_INDEX_TYPE 

228 ) 

229 

230 # If collection_id provided, check for stored settings 

231 if collection_id: 

232 collection = ( 

233 db_session.query(Collection).filter_by(id=collection_id).first() 

234 ) 

235 

236 if collection and collection.embedding_model and not use_defaults: 

237 # Use collection's stored settings 

238 logger.info( 

239 f"Using stored settings for collection {collection_id}: " 

240 f"{collection.embedding_model_type.value if collection.embedding_model_type else 'unknown'}/{collection.embedding_model}" 

241 ) 

242 # Stored values win; NULL columns (normalize_vectors may also 

243 # be stored as a string) take the current settings. The 

244 # service's configuration check resolves them the same way. 

245 stored_settings = resolve_collection_index_settings( 

246 collection, 

247 { 

248 "embedding_provider": default_embedding_provider, 

249 "chunk_size": default_chunk_size, 

250 "chunk_overlap": default_chunk_overlap, 

251 "splitter_type": default_splitter_type, 

252 "text_separators": default_text_separators, 

253 "distance_metric": default_distance_metric, 

254 "normalize_vectors": default_normalize_vectors, 

255 "index_type": default_index_type, 

256 }, 

257 ) 

258 # Egress policy pre-flight (R9-07 / plan landmine #3): 

259 # block before any chunk is generated when the stored 

260 # provider conflicts with require_local. Critical for 

261 # collections indexed pre-policy-rollout with OpenAI. 

262 _enforce_embeddings_policy( 

263 stored_settings["embedding_provider"], settings, username 

264 ) 

265 

266 return LibraryRAGService( 

267 username=username, 

268 embedding_model=collection.embedding_model, 

269 **stored_settings, 

270 db_password=db_password, 

271 ) 

272 if collection: 

273 # New collection - use defaults and store them 

274 logger.info( 

275 f"New collection {collection_id}, using and storing default settings" 

276 ) 

277 

278 # Egress policy pre-flight. 

279 _enforce_embeddings_policy( 

280 default_embedding_provider, settings, username 

281 ) 

282 

283 # Create service with defaults 

284 return LibraryRAGService( 

285 username=username, 

286 embedding_model=default_embedding_model, 

287 embedding_provider=default_embedding_provider, 

288 chunk_size=default_chunk_size, 

289 chunk_overlap=default_chunk_overlap, 

290 splitter_type=default_splitter_type, 

291 text_separators=default_text_separators, 

292 distance_metric=default_distance_metric, 

293 normalize_vectors=default_normalize_vectors, 

294 index_type=default_index_type, 

295 db_password=db_password, 

296 ) 

297 

298 # Store settings on collection (will be done during indexing) 

299 # Note: We don't store here because we don't have 

300 # embedding_dimension yet. It will be stored in 

301 # index_collection when first document is indexed. 

302 

303 # No collection or fallback - use current defaults 

304 # Egress policy pre-flight. 

305 _enforce_embeddings_policy( 

306 default_embedding_provider, settings, username 

307 ) 

308 return LibraryRAGService( 

309 username=username, 

310 embedding_model=default_embedding_model, 

311 embedding_provider=default_embedding_provider, 

312 chunk_size=default_chunk_size, 

313 chunk_overlap=default_chunk_overlap, 

314 splitter_type=default_splitter_type, 

315 text_separators=default_text_separators, 

316 distance_metric=default_distance_metric, 

317 normalize_vectors=default_normalize_vectors, 

318 index_type=default_index_type, 

319 db_password=db_password, 

320 )