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

77 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 LibraryRAGService 

31 

32 

33def _get_default_text_separators(settings): 

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

35 default_text_separators = settings.get_setting( 

36 "local_search_text_separators", 

37 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

38 ) 

39 if isinstance(default_text_separators, str): 

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

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

42 # corrupt data. 

43 try: 

44 default_text_separators = json.loads(default_text_separators) 

45 except json.JSONDecodeError: 

46 logger.warning( 

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

48 default_text_separators, 

49 ) 

50 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

51 

52 if not isinstance(default_text_separators, list): 

53 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

54 

55 return default_text_separators 

56 

57 

58def _enforce_embeddings_policy( 

59 embedding_provider: str, settings_manager, username: str 

60) -> None: 

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

62 

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

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

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

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

67 

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

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

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

71 the flag (see context_from_snapshot). 

72 """ 

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

74 from ...security.egress.policy import ( 

75 DEFAULT_EGRESS_SCOPE, 

76 Decision, 

77 PolicyDeniedError, 

78 context_from_snapshot, 

79 evaluate_embeddings, 

80 ) 

81 

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

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

84 # reads for scope coupling + embedding classification matter. 

85 scope = ( 

86 settings_manager.get_setting("policy.egress_scope") 

87 or DEFAULT_EGRESS_SCOPE 

88 ) 

89 require_local_flag = to_bool( 

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

91 ) 

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

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

94 # evaluate_embeddings() classifies the ollama embeddings endpoint from 

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

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

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

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

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

100 snapshot = { 

101 "policy.egress_scope": scope, 

102 "embeddings.require_local": require_local_flag, 

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

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

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

106 } 

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

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

109 # is concrete. 

110 try: 

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

112 except PolicyDeniedError: 

113 raise 

114 except ValueError as exc: 

115 raise PolicyDeniedError( 

116 Decision(False, "invalid_policy_config"), 

117 target=embedding_provider, 

118 ) from exc 

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

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

121 return 

122 

123 decision = evaluate_embeddings( 

124 embedding_provider, ctx, settings_snapshot=snapshot 

125 ) 

126 if not decision.allowed: 

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

128 "embeddings provider denied by egress policy", 

129 provider=embedding_provider, 

130 reason=decision.reason, 

131 ) 

132 raise PolicyDeniedError(decision, target=embedding_provider) 

133 

134 

135def get_rag_service( 

136 username: str, 

137 collection_id: Optional[str] = None, 

138 use_defaults: bool = False, 

139 db_password: Optional[str] = None, 

140) -> LibraryRAGService: 

141 """ 

142 Get RAG service instance with appropriate settings. 

143 

144 Args: 

145 username: Username for database access and settings lookup 

146 collection_id: Optional collection UUID to load stored settings from 

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

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

149 default embedding model is picked up. 

150 db_password: Optional database password for encrypted databases 

151 

152 If collection_id is provided: 

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

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

155 

156 If no collection_id: 

157 - Uses current default settings 

158 """ 

159 # Use get_user_db_session so that settings are readable from background 

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

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

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

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

164 with get_user_db_session(username, db_password) as db_session: 

165 settings = get_settings_manager( 

166 db_session=db_session, username=username 

167 ) 

168 

169 # Get current default settings. 

170 # Explicit fallbacks to centralized constants ensure safe operation 

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

172 raw_embedding_model = settings.get_setting( 

173 "local_search_embedding_model" 

174 ) 

175 raw_embedding_provider = settings.get_setting( 

176 "local_search_embedding_provider" 

177 ) 

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

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

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

181 # regression it would fire on every indexing call. 

182 if not raw_embedding_model and not raw_embedding_provider: 

183 logger.warning( 

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

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

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

187 "otherwise check that db_session is being passed to " 

188 "SettingsManager (see #3453)." 

189 ) 

190 default_embedding_model = ( 

191 raw_embedding_model or DEFAULT_LOCAL_SEARCH_MODEL 

192 ) 

193 default_embedding_provider = ( 

194 raw_embedding_provider or DEFAULT_LOCAL_SEARCH_PROVIDER 

195 ) 

196 raw_chunk_size = settings.get_setting("local_search_chunk_size") 

197 default_chunk_size = ( 

198 int(raw_chunk_size) 

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

200 else DEFAULT_LOCAL_SEARCH_CHUNK_SIZE 

201 ) 

202 raw_chunk_overlap = settings.get_setting("local_search_chunk_overlap") 

203 default_chunk_overlap = ( 

204 int(raw_chunk_overlap) 

205 if raw_chunk_overlap is not None 

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

207 else DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP 

208 ) 

209 default_splitter_type = ( 

210 settings.get_setting("local_search_splitter_type") 

211 or DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE 

212 ) 

213 default_text_separators = _get_default_text_separators(settings) 

214 default_distance_metric = ( 

215 settings.get_setting("local_search_distance_metric") 

216 or DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC 

217 ) 

218 default_normalize_vectors = to_bool( 

219 settings.get_setting("local_search_normalize_vectors"), 

220 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

221 ) 

222 default_index_type = ( 

223 settings.get_setting("local_search_index_type") 

224 or DEFAULT_LOCAL_SEARCH_INDEX_TYPE 

225 ) 

226 

227 # If collection_id provided, check for stored settings 

228 if collection_id: 

229 collection = ( 

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

231 ) 

232 

233 if collection and collection.embedding_model and not use_defaults: 

234 # Use collection's stored settings 

235 logger.info( 

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

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

238 ) 

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

240 # block before any chunk is generated when the stored 

241 # provider conflicts with require_local. Critical for 

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

243 effective_provider = ( 

244 collection.embedding_model_type.value 

245 if collection.embedding_model_type 

246 else default_embedding_provider 

247 ) 

248 _enforce_embeddings_policy( 

249 effective_provider, settings, username 

250 ) 

251 

252 # Handle normalize_vectors - may be stored as string in some 

253 # cases 

254 coll_normalize = collection.normalize_vectors 

255 if coll_normalize is not None: 

256 coll_normalize = to_bool(coll_normalize) 

257 else: 

258 coll_normalize = default_normalize_vectors 

259 

260 def _col(stored, default): 

261 """Use stored collection value if not None, else default.""" 

262 return stored if stored is not None else default 

263 

264 return LibraryRAGService( 

265 username=username, 

266 embedding_model=collection.embedding_model, 

267 embedding_provider=collection.embedding_model_type.value 

268 if collection.embedding_model_type 

269 else default_embedding_provider, 

270 chunk_size=_col(collection.chunk_size, default_chunk_size), 

271 chunk_overlap=_col( 

272 collection.chunk_overlap, default_chunk_overlap 

273 ), 

274 splitter_type=_col( 

275 collection.splitter_type, default_splitter_type 

276 ), 

277 text_separators=_col( 

278 collection.text_separators, default_text_separators 

279 ), 

280 distance_metric=_col( 

281 collection.distance_metric, default_distance_metric 

282 ), 

283 normalize_vectors=coll_normalize, 

284 index_type=_col(collection.index_type, default_index_type), 

285 db_password=db_password, 

286 ) 

287 if collection: 

288 # New collection - use defaults and store them 

289 logger.info( 

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

291 ) 

292 

293 # Egress policy pre-flight. 

294 _enforce_embeddings_policy( 

295 default_embedding_provider, settings, username 

296 ) 

297 

298 # Create service with defaults 

299 return LibraryRAGService( 

300 username=username, 

301 embedding_model=default_embedding_model, 

302 embedding_provider=default_embedding_provider, 

303 chunk_size=default_chunk_size, 

304 chunk_overlap=default_chunk_overlap, 

305 splitter_type=default_splitter_type, 

306 text_separators=default_text_separators, 

307 distance_metric=default_distance_metric, 

308 normalize_vectors=default_normalize_vectors, 

309 index_type=default_index_type, 

310 db_password=db_password, 

311 ) 

312 

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

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

315 # embedding_dimension yet. It will be stored in 

316 # index_collection when first document is indexed. 

317 

318 # No collection or fallback - use current defaults 

319 # Egress policy pre-flight. 

320 _enforce_embeddings_policy( 

321 default_embedding_provider, settings, username 

322 ) 

323 return LibraryRAGService( 

324 username=username, 

325 embedding_model=default_embedding_model, 

326 embedding_provider=default_embedding_provider, 

327 chunk_size=default_chunk_size, 

328 chunk_overlap=default_chunk_overlap, 

329 splitter_type=default_splitter_type, 

330 text_separators=default_text_separators, 

331 distance_metric=default_distance_metric, 

332 normalize_vectors=default_normalize_vectors, 

333 index_type=default_index_type, 

334 db_password=db_password, 

335 )