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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2RAG Service Factory
4Provides get_rag_service() for creating LibraryRAGService instances
5with appropriate settings. Extracted from rag_routes.py to avoid
6circular imports (service → routes).
7"""
9import json
10from typing import Optional
12from loguru import logger
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
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
52 if not isinstance(default_text_separators, list):
53 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS
55 return default_text_separators
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.
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.
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 )
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
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)
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.
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
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)
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 )
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 )
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 )
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 )
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
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
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 )
293 # Egress policy pre-flight.
294 _enforce_embeddings_policy(
295 default_embedding_provider, settings, username
296 )
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 )
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.
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 )