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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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 (
31 LibraryRAGService,
32 resolve_collection_index_settings,
33)
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
55 if not isinstance(default_text_separators, list):
56 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS
58 return default_text_separators
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.
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.
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 )
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
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)
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.
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
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)
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 )
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 )
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 )
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 )
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 )
278 # Egress policy pre-flight.
279 _enforce_embeddings_policy(
280 default_embedding_provider, settings, username
281 )
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 )
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.
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 )