Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-rag/src/lexigram/ai/rag/pipeline/builder.py: 24%
199 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1from __future__ import annotations
3from pathlib import Path
4from typing import TYPE_CHECKING, Any
6import yaml
8from lexigram.ai.rag.config import (
9 PipelineConfig,
10 PipelineStageType,
11)
12from lexigram.ai.rag.exceptions import MissingCitationsError
13from lexigram.ai.rag.pipeline._stage_factory import build_pipeline_stages
14from lexigram.ai.rag.pipeline.base import PipelineStageProtocol
15from lexigram.ai.rag.pipeline.executor import PipelineExecutor
16from lexigram.ai.rag.pipeline.types import ErrorStrategy
17from lexigram.contracts.ai.exceptions import RAGError
18from lexigram.contracts.ai.rag import RAGContext, RAGEvaluatorProtocol, RAGResponse
19from lexigram.logging import (
20 get_logger,
21)
22from lexigram.result import Err, Ok, Result
24if TYPE_CHECKING:
25 from lexigram.ai.rag.pipeline.types import PipelineContext
26 from lexigram.contracts.ai.memory import WorkingMemoryProtocol
28from lexigram.primitives.builder import AbstractBuilder
30logger = get_logger(__name__)
33class RAGPipeline:
34 """Main RAG pipeline that orchestrates all stages.
36 This class provides a simple interface for executing the complete
37 RAG pipeline with configurable stages and error handling.
38 """
40 def __init__(
41 self,
42 config: PipelineConfig,
43 stages: list[PipelineStageProtocol],
44 evaluator: RAGEvaluatorProtocol | None = None,
45 working_memory: WorkingMemoryProtocol | None = None,
46 ):
47 """Initialize the RAG pipeline.
49 Args:
50 config: Pipeline configuration
51 stages: List of pipeline stages
52 evaluator: Optional evaluator implementing
53 :class:`~lexigram.contracts.ai.rag.RAGEvaluatorProtocol` for
54 automatic per-request quality evaluation. Evaluation
55 frequency is controlled by :attr:`~lexigram.ai.rag.config.PipelineConfig.auto_evaluate_every_n`.
56 working_memory: Optional working memory for context enrichment.
57 """
58 self.config = config
59 self.stages = stages
60 self.evaluator = evaluator
61 self._working_memory = working_memory
62 self._request_count: int = 0
63 error_strategy = config.default_error_strategy
64 if not isinstance(error_strategy, ErrorStrategy):
65 if isinstance(error_strategy, str):
66 error_strategy = ErrorStrategy(error_strategy)
67 else:
68 error_strategy = ErrorStrategy.GRACEFUL
70 self.executor = PipelineExecutor(
71 stages=stages,
72 default_error_strategy=error_strategy,
73 max_retries=config.max_retries,
74 retry_delay=config.retry_delay,
75 )
77 async def run(
78 self,
79 query: str,
80 documents: list[str] | None = None,
81 document_paths: list[str] | None = None,
82 metadata: dict[str, Any] | None = None,
83 ) -> PipelineContext:
84 """Execute the RAG pipeline.
86 Args:
87 query: User query
88 documents: Optional list of document content strings
89 document_paths: Optional list of document file paths
90 metadata: Optional custom metadata
92 Returns:
93 Pipeline context with results
94 """
95 from lexigram.ai.rag.pipeline.types import PipelineContext
97 # Create initial context
98 context = PipelineContext(
99 query=query,
100 documents=documents or [],
101 document_paths=document_paths or [],
102 metadata=metadata or {},
103 )
105 # Enrich query with memory context if working memory is available
106 if self._working_memory:
107 try:
108 memory_entries = await self._working_memory.assemble(
109 query=query,
110 token_budget=1024,
111 owner_id=(metadata or {}).get("user_id")
112 or (metadata or {}).get("session_id")
113 or "anonymous",
114 )
115 if memory_entries:
116 memory_context = "\n".join(
117 f"[{e.role}]: {e.content}" for e in memory_entries
118 )
119 context.metadata["memory_context"] = memory_context
120 context.metadata["memory_entries_count"] = len(memory_entries)
121 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
122 logger.warning("rag_memory_enrichment_failed", error=str(e))
124 # Execute pipeline
125 context = await self.executor.execute(context)
127 # Citation enforcement (P7.2) — fail fast before incrementing counters
128 if (
129 self.config.require_citations
130 and context.synthesis_result is not None
131 and not context.synthesis_result.citations
132 ):
133 msg = (
134 f"Pipeline '{self.config.name}' requires citations but none were "
135 "produced by the synthesis stage."
136 )
137 raise MissingCitationsError(msg)
139 # Auto-evaluation hook (P7.1)
140 self._request_count += 1
141 if (
142 self.evaluator is not None
143 and self.config.auto_evaluate_every_n is not None
144 and self._request_count % self.config.auto_evaluate_every_n == 0
145 and context.synthesis_result is not None
146 ):
147 docs = context.optimized_chunks or context.retrieved_chunks
148 try:
149 report = await self.evaluator.evaluate(
150 query=context.query,
151 retrieved_docs=docs,
152 generated_answer=context.synthesis_result.response,
153 )
154 context.metadata["evaluation_report"] = report
155 except (RuntimeError, TypeError, AttributeError, OSError, ValueError):
156 logger.warning(
157 "Auto-evaluation failed for request %s",
158 context.request_id,
159 exc_info=True,
160 )
162 return context
164 async def execute(self, context: RAGContext) -> Result[RAGResponse, RAGError]:
165 """Execute the RAG pipeline per the contract protocol.
167 Args:
168 context: Pipeline context with query and optional config/filters.
170 Returns:
171 Ok(RAGResponse) on success, Err(RAGError) on failure.
172 """
173 try:
174 pipeline_ctx = await self.run(
175 query=context.query,
176 metadata=context.filters,
177 )
178 synthesis = pipeline_ctx.synthesis_result
179 answer = synthesis.response if synthesis is not None else ""
180 citations = synthesis.citations if synthesis is not None else None
181 response = RAGResponse(
182 answer=answer,
183 sources=[],
184 citations=citations,
185 )
186 return Ok(response)
187 except RAGError as exc:
188 return Err(exc)
190 async def run_parallel(
191 self,
192 query: str,
193 stages: list[PipelineStageProtocol] | None = None,
194 **kwargs: Any,
195 ) -> PipelineContext:
196 """Execute pipeline stages in parallel.
198 Args:
199 query: User query
200 stages: Stages to execute in parallel (default: all stages)
201 **kwargs: Additional context parameters
203 Returns:
204 Pipeline context with results
205 """
206 from lexigram.ai.rag.pipeline.types import PipelineContext
208 context = PipelineContext(query=query, **kwargs)
209 return await self.executor.execute_parallel(context, stages)
212class PipelineBuilder(AbstractBuilder[RAGPipeline]):
213 """Builder for constructing RAG pipelines with fluent API.
215 The builder provides a convenient way to configure and build
216 pipelines programmatically or from configuration files.
217 """
219 def __init__(self) -> None:
220 """Initialize the pipeline builder."""
221 super().__init__()
222 self.config = PipelineConfig()
223 self._custom_stages: list[PipelineStageProtocol] = []
225 def with_name(self, name: str) -> PipelineBuilder:
226 """Set pipeline name.
228 Args:
229 name: Pipeline name
231 Returns:
232 Self for chaining
233 """
234 self.config.name = name
235 return self
237 def with_description(self, description: str) -> PipelineBuilder:
238 """Set pipeline description.
240 Args:
241 description: Pipeline description
243 Returns:
244 Self for chaining
245 """
246 self.config.description = description
247 return self
249 def with_ingestion(self, **kwargs: Any) -> PipelineBuilder:
250 """Configure ingestion stage.
252 Args:
253 **kwargs: Ingestion configuration parameters
255 Returns:
256 Self for chaining
257 """
258 for key, value in kwargs.items():
259 if hasattr(self.config.ingestion, key):
260 setattr(self.config.ingestion, key, value)
261 return self
263 def with_query_processing(self, **kwargs: Any) -> PipelineBuilder:
264 """Configure query processing stage.
266 Args:
267 **kwargs: Query processing configuration parameters
269 Returns:
270 Self for chaining
271 """
272 for key, value in kwargs.items():
273 if hasattr(self.config.query_processing, key):
274 setattr(self.config.query_processing, key, value)
275 return self
277 def with_retrieval(self, **kwargs: Any) -> PipelineBuilder:
278 """Configure retrieval stage.
280 Args:
281 **kwargs: Retrieval configuration parameters
283 Returns:
284 Self for chaining
285 """
286 for key, value in kwargs.items():
287 if hasattr(self.config.retrieval, key):
288 setattr(self.config.retrieval, key, value)
289 return self
291 def with_context_optimization(self, **kwargs: Any) -> PipelineBuilder:
292 """Configure context optimization stage.
294 Args:
295 **kwargs: Context optimization configuration parameters
297 Returns:
298 Self for chaining
299 """
300 for key, value in kwargs.items():
301 if hasattr(self.config.context_optimization, key):
302 setattr(self.config.context_optimization, key, value)
303 return self
305 def with_synthesis(self, **kwargs: Any) -> PipelineBuilder:
306 """Configure synthesis stage.
308 Args:
309 **kwargs: Synthesis configuration parameters
311 Returns:
312 Self for chaining
313 """
314 for key, value in kwargs.items():
315 if hasattr(self.config.synthesis, key):
316 setattr(self.config.synthesis, key, value)
317 return self
319 def with_quality_assurance(self, **kwargs: Any) -> PipelineBuilder:
320 """Configure quality assurance stage.
322 Args:
323 **kwargs: Quality assurance configuration parameters
325 Returns:
326 Self for chaining
327 """
328 for key, value in kwargs.items():
329 if hasattr(self.config.quality_assurance, key):
330 setattr(self.config.quality_assurance, key, value)
331 return self
333 def with_post_processing(self, **kwargs: Any) -> PipelineBuilder:
334 """Configure post-processing stage.
336 Args:
337 **kwargs: Post-processing configuration parameters
339 Returns:
340 Self for chaining
341 """
342 for key, value in kwargs.items():
343 if hasattr(self.config.post_processing, key):
344 setattr(self.config.post_processing, key, value)
345 return self
347 def with_error_strategy(
348 self,
349 strategy: ErrorStrategy,
350 max_retries: int = 3,
351 retry_delay: float = 1.0,
352 ) -> PipelineBuilder:
353 """Configure global error handling.
355 Args:
356 strategy: Default error handling strategy
357 max_retries: Maximum number of retries
358 retry_delay: Initial delay between retries
360 Returns:
361 Self for chaining
362 """
363 self.config.default_error_strategy = strategy
364 self.config.max_retries = max_retries
365 self.config.retry_delay = retry_delay
366 return self
368 def with_stages(self, stages: list[PipelineStageType]) -> PipelineBuilder:
369 """Set the ordered list of pipeline stages.
371 Args:
372 stages: List of stages
374 Returns:
375 Self for chaining
376 """
377 self.config.stages = stages
378 return self
380 def with_custom_stage(self, stage: PipelineStageProtocol) -> PipelineBuilder:
381 """Add a custom pipeline stage.
383 Args:
384 stage: Custom pipeline stage
386 Returns:
387 Self for chaining
388 """
389 self._custom_stages.append(stage)
390 return self
392 def from_dict(self, config_dict: dict[str, Any]) -> PipelineBuilder:
393 """Load configuration from dictionary.
395 Args:
396 config_dict: Configuration dictionary
398 Returns:
399 Self for chaining
400 """
401 self.config = PipelineConfig.from_dict(config_dict)
402 return self
404 async def from_yaml(self, yaml_path: str | Path) -> PipelineBuilder:
405 """Load configuration from YAML file.
407 Args:
408 yaml_path: Path to YAML configuration file
410 Returns:
411 Self for chaining
412 """
413 import asyncio
415 def _load_yaml() -> Any:
416 with open(yaml_path) as f:
417 return yaml.safe_load(f.read())
419 config_dict = await asyncio.to_thread(_load_yaml)
420 return self.from_dict(config_dict)
422 # -----------------------------------------------------------------
423 # High-level convenience API (Phase 10 DX)
424 # -----------------------------------------------------------------
426 def retrieve(
427 self,
428 strategy: str = "hybrid",
429 top_k: int = 10,
430 **kwargs: Any,
431 ) -> PipelineBuilder:
432 """Configure retrieval with high-level parameters.
434 Args:
435 strategy: Retrieval strategy name (``"hybrid"``, ``"dense"``,
436 ``"sparse"``).
437 top_k: Number of chunks to retrieve.
438 **kwargs: Additional retrieval parameters.
439 """
440 self.config.retrieval.enabled = True
441 self.config.retrieval.strategy = strategy
442 self.config.retrieval.top_k = top_k
443 for key, value in kwargs.items():
444 if hasattr(self.config.retrieval, key):
445 setattr(self.config.retrieval, key, value)
446 return self
448 def rerank(
449 self,
450 strategy: str = "cross-encoder",
451 top_k: int = 5,
452 **kwargs: Any,
453 ) -> PipelineBuilder:
454 """Configure context optimization / reranking.
456 Args:
457 strategy: Reranking strategy name.
458 top_k: Number of chunks to keep after reranking.
459 **kwargs: Additional reranking parameters.
460 """
461 self.config.context_optimization.enabled = True
462 self.config.context_optimization.strategy = strategy
463 self.config.context_optimization.top_k = top_k
464 for key, value in kwargs.items():
465 if hasattr(self.config.context_optimization, key):
466 setattr(self.config.context_optimization, key, value)
467 return self
469 def synthesize(
470 self,
471 strategy: str = "abstractive",
472 model: str | None = None,
473 **kwargs: Any,
474 ) -> PipelineBuilder:
475 """Configure synthesis with high-level parameters.
477 Args:
478 strategy: Synthesis strategy (``"abstractive"``, ``"extractive"``).
479 model: LLM model identifier for generation.
480 **kwargs: Additional synthesis parameters.
481 """
482 self.config.synthesis.enabled = True
483 self.config.synthesis.strategy = strategy # type: ignore[assignment]
484 if model is not None:
485 self.config.synthesis.model = model
486 for key, value in kwargs.items():
487 if hasattr(self.config.synthesis, key):
488 setattr(self.config.synthesis, key, value)
489 return self
491 def with_citations(self, required: bool = True) -> PipelineBuilder:
492 """Enable citation tracking in the pipeline.
494 Args:
495 required: Whether citations are required (pipeline fails without
496 them if set to ``True``).
497 """
498 self.config.require_citations = required
499 return self
501 def with_evaluation(
502 self,
503 evaluator: RAGEvaluatorProtocol | None = None,
504 every_n: int = 1,
505 ) -> PipelineBuilder:
506 """Enable automatic evaluation of pipeline outputs.
508 Args:
509 evaluator: Optional pre-built evaluator instance.
510 every_n: Evaluate every *n*-th request (default: every request).
511 """
512 self._evaluator = evaluator
513 self.config.auto_evaluate_every_n = every_n
514 return self
516 def with_timeout(self, **stage_timeouts: float) -> PipelineBuilder:
517 """Set per-stage timeouts (in seconds).
519 Keyword arguments map stage names to their timeout values::
521 builder.with_timeout(retrieval=10.0, synthesis=30.0)
523 Args:
524 **stage_timeouts: Mapping of stage name to timeout in seconds.
525 """
526 if not hasattr(self.config, "stage_timeouts"):
527 setattr(self.config, "stage_timeouts", {}) # noqa: B010
528 self.config.stage_timeouts.update(stage_timeouts) # type: ignore[attr-defined]
529 return self
531 def with_working_memory(
532 self,
533 memory: WorkingMemoryProtocol,
534 ) -> PipelineBuilder:
535 """Attach working memory for context enrichment.
537 Args:
538 memory: Working memory instance.
539 """
540 self._working_memory = memory
541 return self
543 def build(self) -> RAGPipeline:
544 """Build the RAG pipeline.
546 Returns:
547 Configured RAG pipeline
549 Raises:
550 ValueError: If configuration is invalid
551 """
552 # Validate configuration
553 self._validate_config()
555 # Build stages
556 stages = self._build_stages()
558 # Add custom stages
559 stages.extend(self._custom_stages)
561 # Create pipeline
562 return RAGPipeline(
563 config=self.config,
564 stages=stages,
565 evaluator=getattr(self, "_evaluator", None),
566 working_memory=getattr(self, "_working_memory", None),
567 )
569 def _validate_config(self) -> None:
570 """Validate pipeline configuration.
572 Raises:
573 ValueError: If configuration is invalid
574 """
575 # Check that at least synthesis is enabled
576 if not self.config.synthesis.enabled:
577 msg = "Synthesis must be enabled in pipeline configuration"
578 raise ValueError(msg)
580 # Check that retrieval is enabled if synthesis is enabled
581 if self.config.synthesis.enabled and not self.config.retrieval.enabled:
582 raise ValueError(
583 "Retrieval stage must be enabled when synthesis is enabled",
584 )
586 # Validate quality thresholds
587 qa_config = self.config.quality_assurance
588 if qa_config.enabled:
589 if not (0 <= qa_config.min_faithfulness <= 1):
590 msg = "qa_config.min_faithfulness must be between 0 and 1"
591 raise ValueError(msg)
592 if not (0 <= qa_config.min_relevance <= 1):
593 msg = "qa_config.min_relevance must be between 0 and 1"
594 raise ValueError(msg)
595 if not (0 <= qa_config.min_confidence <= 1):
596 msg = "qa_config.min_confidence must be between 0 and 1"
597 raise ValueError(msg)
599 def _build_stages(self) -> list[PipelineStageProtocol]:
600 """Build pipeline stages based on configuration.
602 Returns:
603 List of configured pipeline stages.
604 """
605 return build_pipeline_stages(self.config)