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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +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_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
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
44 if not isinstance(default_text_separators, list):
45 default_text_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS
47 return default_text_separators
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.
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.
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 )
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
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)
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.
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
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)
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 )
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 )
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 )
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 )
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
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
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 )
273 # Egress policy pre-flight.
274 _enforce_embeddings_policy(
275 default_embedding_provider, settings, username
276 )
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 )
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.
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 )