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

75 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +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_TEXT_SEPARATORS, 

16 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

17) 

18from ...database.models.library import Collection 

19from ...database.session_context import get_user_db_session 

20from ...utilities.db_utils import get_settings_manager 

21from ...utilities.type_utils import to_bool 

22from ..services.library_rag_service import LibraryRAGService 

23 

24 

25def _get_default_text_separators(settings): 

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

27 default_text_separators = settings.get_setting( 

28 "local_search_text_separators", 

29 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON, 

30 ) 

31 if isinstance(default_text_separators, str): 

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

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

34 # corrupt data. 

35 try: 

36 default_text_separators = json.loads(default_text_separators) 

37 except json.JSONDecodeError: 

38 logger.warning( 

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

40 default_text_separators, 

41 ) 

42 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

43 

44 if not isinstance(default_text_separators, list): 

45 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

46 

47 return default_text_separators 

48 

49 

50def _enforce_embeddings_policy( 

51 embedding_provider: str, settings_manager, username: str 

52) -> None: 

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

54 

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

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

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

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

59 

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

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

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

63 the flag (see context_from_snapshot). 

64 """ 

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

66 from ...security.egress.policy import ( 

67 DEFAULT_EGRESS_SCOPE, 

68 Decision, 

69 PolicyDeniedError, 

70 context_from_snapshot, 

71 evaluate_embeddings, 

72 ) 

73 

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

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

76 # reads for scope coupling + embedding classification matter. 

77 scope = ( 

78 settings_manager.get_setting("policy.egress_scope") 

79 or DEFAULT_EGRESS_SCOPE 

80 ) 

81 require_local_flag = to_bool( 

82 settings_manager.get_setting("embeddings.require_local") or False 

83 ) 

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

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

86 # evaluate_embeddings() classifies the ollama embeddings endpoint from 

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

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

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

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

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

92 snapshot = { 

93 "policy.egress_scope": scope, 

94 "embeddings.require_local": require_local_flag, 

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

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

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

98 } 

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

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

101 # is concrete. 

102 try: 

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

104 except PolicyDeniedError: 

105 raise 

106 except ValueError as exc: 

107 raise PolicyDeniedError( 

108 Decision(False, "invalid_policy_config"), 

109 target=embedding_provider, 

110 ) from exc 

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

112 if not ctx.require_local_embeddings: 

113 return 

114 

115 decision = evaluate_embeddings( 

116 embedding_provider, ctx, settings_snapshot=snapshot 

117 ) 

118 if not decision.allowed: 

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

120 "embeddings provider denied by egress policy", 

121 provider=embedding_provider, 

122 reason=decision.reason, 

123 ) 

124 raise PolicyDeniedError(decision, target=embedding_provider) 

125 

126 

127def get_rag_service( 

128 username: str, 

129 collection_id: Optional[str] = None, 

130 use_defaults: bool = False, 

131 db_password: Optional[str] = None, 

132) -> LibraryRAGService: 

133 """ 

134 Get RAG service instance with appropriate settings. 

135 

136 Args: 

137 username: Username for database access and settings lookup 

138 collection_id: Optional collection UUID to load stored settings from 

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

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

141 default embedding model is picked up. 

142 db_password: Optional database password for encrypted databases 

143 

144 If collection_id is provided: 

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

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

147 

148 If no collection_id: 

149 - Uses current default settings 

150 """ 

151 # Use get_user_db_session so that settings are readable from background 

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

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

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

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

156 with get_user_db_session(username, db_password) as db_session: 

157 settings = get_settings_manager( 

158 db_session=db_session, username=username 

159 ) 

160 

161 # Get current default settings. 

162 # The local_search_* keys are written by the embedding-settings page 

163 # and have no JSON defaults file yet, so explicit fallbacks are 

164 # required to avoid TypeError / None propagation on fresh installs. 

165 raw_embedding_model = settings.get_setting( 

166 "local_search_embedding_model" 

167 ) 

168 raw_embedding_provider = settings.get_setting( 

169 "local_search_embedding_provider" 

170 ) 

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

172 # instead of being masked by `or`-chained defaults. On fresh installs 

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

174 # regression it would fire on every indexing call. 

175 if not raw_embedding_model and not raw_embedding_provider: 

176 logger.warning( 

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

178 "hardcoded defaults (sentence_transformers/all-MiniLM-L6-v2). " 

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

180 "otherwise check that db_session is being passed to " 

181 "SettingsManager (see #3453)." 

182 ) 

183 default_embedding_model = raw_embedding_model or "all-MiniLM-L6-v2" 

184 default_embedding_provider = ( 

185 raw_embedding_provider or "sentence_transformers" 

186 ) 

187 default_chunk_size = int( 

188 settings.get_setting("local_search_chunk_size") or 1000 

189 ) 

190 default_chunk_overlap = int( 

191 settings.get_setting("local_search_chunk_overlap") or 200 

192 ) 

193 default_splitter_type = ( 

194 settings.get_setting("local_search_splitter_type") or "recursive" 

195 ) 

196 default_text_separators = _get_default_text_separators(settings) 

197 default_distance_metric = ( 

198 settings.get_setting("local_search_distance_metric") or "cosine" 

199 ) 

200 default_normalize_vectors = settings.get_bool_setting( 

201 "local_search_normalize_vectors" 

202 ) 

203 default_index_type = ( 

204 settings.get_setting("local_search_index_type") or "flat" 

205 ) 

206 

207 # If collection_id provided, check for stored settings 

208 if collection_id: 

209 collection = ( 

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

211 ) 

212 

213 if collection and collection.embedding_model and not use_defaults: 

214 # Use collection's stored settings 

215 logger.info( 

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

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

218 ) 

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

220 # block before any chunk is generated when the stored 

221 # provider conflicts with require_local. Critical for 

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

223 effective_provider = ( 

224 collection.embedding_model_type.value 

225 if collection.embedding_model_type 

226 else default_embedding_provider 

227 ) 

228 _enforce_embeddings_policy( 

229 effective_provider, settings, username 

230 ) 

231 

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

233 # cases 

234 coll_normalize = collection.normalize_vectors 

235 if coll_normalize is not None: 

236 coll_normalize = to_bool(coll_normalize) 

237 else: 

238 coll_normalize = default_normalize_vectors 

239 

240 def _col(stored, default): 

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

242 return stored if stored is not None else default 

243 

244 return LibraryRAGService( 

245 username=username, 

246 embedding_model=collection.embedding_model, 

247 embedding_provider=collection.embedding_model_type.value 

248 if collection.embedding_model_type 

249 else default_embedding_provider, 

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

251 chunk_overlap=_col( 

252 collection.chunk_overlap, default_chunk_overlap 

253 ), 

254 splitter_type=_col( 

255 collection.splitter_type, default_splitter_type 

256 ), 

257 text_separators=_col( 

258 collection.text_separators, default_text_separators 

259 ), 

260 distance_metric=_col( 

261 collection.distance_metric, default_distance_metric 

262 ), 

263 normalize_vectors=coll_normalize, 

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

265 db_password=db_password, 

266 ) 

267 if collection: 

268 # New collection - use defaults and store them 

269 logger.info( 

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

271 ) 

272 

273 # Egress policy pre-flight. 

274 _enforce_embeddings_policy( 

275 default_embedding_provider, settings, username 

276 ) 

277 

278 # Create service with defaults 

279 return LibraryRAGService( 

280 username=username, 

281 embedding_model=default_embedding_model, 

282 embedding_provider=default_embedding_provider, 

283 chunk_size=default_chunk_size, 

284 chunk_overlap=default_chunk_overlap, 

285 splitter_type=default_splitter_type, 

286 text_separators=default_text_separators, 

287 distance_metric=default_distance_metric, 

288 normalize_vectors=default_normalize_vectors, 

289 index_type=default_index_type, 

290 db_password=db_password, 

291 ) 

292 

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

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

295 # embedding_dimension yet. It will be stored in 

296 # index_collection when first document is indexed. 

297 

298 # No collection or fallback - use current defaults 

299 # Egress policy pre-flight. 

300 _enforce_embeddings_policy( 

301 default_embedding_provider, settings, username 

302 ) 

303 return LibraryRAGService( 

304 username=username, 

305 embedding_model=default_embedding_model, 

306 embedding_provider=default_embedding_provider, 

307 chunk_size=default_chunk_size, 

308 chunk_overlap=default_chunk_overlap, 

309 splitter_type=default_splitter_type, 

310 text_separators=default_text_separators, 

311 distance_metric=default_distance_metric, 

312 normalize_vectors=default_normalize_vectors, 

313 index_type=default_index_type, 

314 db_password=db_password, 

315 )