Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
push:
# dev-fran es la rama por defecto de este repositorio, no main.
branches: [dev-fran, main]
# Sin filtro de rama: una PR debe validarse apunte a donde apunte.
pull_request:

permissions:
contents: read

jobs:
test:
name: Lint y tests
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.12']

steps:
- uses: actions/checkout@v4

- name: Instalar uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

# Un entorno por versión de la matriz. Sin él, uv resolvería contra el
# Python del sistema y las dos jobs correrían sobre el mismo intérprete.
- name: Crear entorno con Python ${{ matrix.python-version }}
run: uv venv --python ${{ matrix.python-version }}

# Sin el extra 'ingestion' a propósito: docling y transformers son
# opcionales, y el CI comprueba justamente que el proyecto se importa y
# se testea sin ellos.
- name: Instalar dependencias
run: uv pip install -e . 'pytest>=8.3' 'pytest-asyncio>=0.24' 'ruff>=0.8'

- name: Comprobar formato
run: uv run ruff format --check src/ tests/

- name: Comprobar lint
run: uv run ruff check src/ tests/

- name: Ejecutar tests
run: uv run pytest -q
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Francisco Manuel Olmedo Cortés

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Hybrid RAG Agent - Clean Architecture

[![CI](https://github.com/FullFran/Hybrid-RAG-example/actions/workflows/ci.yml/badge.svg)](https://github.com/FullFran/Hybrid-RAG-example/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Modern and modular RAG (Retrieval-Augmented Generation) system designed under **Clean Architecture** principles. This system enables intelligent document retrieval with total independence from infrastructure providers (Database, LLM, or Embeddings).

## 🏛️ Architecture: Clean RAG Design
Expand Down
62 changes: 0 additions & 62 deletions debug_db.py

This file was deleted.

27 changes: 27 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,30 @@ dev = [
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

[tool.ruff]
target-version = "py310"

[tool.ruff.lint]
# Conjunto explícito y no el de por defecto: los valores por defecto de ruff
# cambian entre versiones, y un CI que depende de ellos se rompe solo el día
# que alguien actualiza la herramienta.
select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"PIE", # flake8-pie
"SIM", # flake8-simplify
]

[tool.ruff.lint.per-file-ignores]
# Los ejemplos priorizan la legibilidad didáctica sobre el estilo idiomático.
"examples/*" = ["E501"]
# Los prompts son datos, no código. Partir una línea de prompt para que quepa
# en 88 columnas cambia el texto que recibe el modelo y empeora la lectura del
# propio prompt, que es lo que aquí importa revisar.
"src/core/prompts.py" = ["E501"]
"src/services/agent_service.py" = ["E501"]
"src/endpoints/cli/main.py" = ["E501"]
"src/core/interfaces/parser.py" = ["E501"]
17 changes: 13 additions & 4 deletions src/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from src.core.dtos import SearchOptions
from src.services.ingest_service import IngestService
from src.services.rag_service import RAGService
from src.settings import load_settings
Expand All @@ -15,7 +16,6 @@ def _get_repository(settings):
return SupabaseRepository(
url=settings.supabase_url,
key=settings.supabase_key,
threshold=settings.semantic_match_threshold,
)
else:
from src.infrastructure.database.mongo_repository import MongoRepository
Expand Down Expand Up @@ -53,16 +53,21 @@ def bootstrap_rag_service() -> RAGService:
max_per_document=2,
)

return RAGService(repository, llm, embedder, context_builder)
# The similarity threshold is retrieval policy, so it is configured here
# in the application layer and travels with each query, instead of being
# baked into the adapter at construction time.
default_options = SearchOptions(threshold=settings.semantic_match_threshold)

return RAGService(repository, llm, embedder, context_builder, default_options)


def bootstrap_ingest_service() -> IngestService:
settings = load_settings()
repository = _get_repository(settings)

from src.infrastructure.embeddings.openai_embedder import OpenAIEmbedder
from src.infrastructure.ingestion.docling_parser import DoclingParser
from src.infrastructure.ingestion.docling_chunker import DoclingChunker
from src.infrastructure.ingestion.docling_parser import DoclingParser

embedder = OpenAIEmbedder(
api_key=settings.embedding_api_key,
Expand All @@ -73,7 +78,11 @@ def bootstrap_ingest_service() -> IngestService:
parser = DoclingParser()
chunker = DoclingChunker(max_tokens=settings.embedding_dimension)

return IngestService(repository, embedder, parser, chunker)
# The concrete repository also implements IAdminRepository, so ingestion
# gets destructive access explicitly rather than by accident.
return IngestService(
repository, embedder, parser, chunker, admin_repository=repository
)


def bootstrap_agent_service():
Expand Down
6 changes: 3 additions & 3 deletions src/core/dtos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"""

from dataclasses import dataclass, field
from typing import Any, List
from typing import Any

from src.core.schemas.search import SearchHit, SearchType

Expand All @@ -28,7 +28,7 @@ class SearchResponse:
"""Response from a search operation."""

query: str
hits: List[SearchHit]
hits: list[SearchHit]
total_hits: int
search_type: SearchType

Expand All @@ -47,5 +47,5 @@ class ContextResult:
"""Result from context building with citations."""

context: str
citations: List[Citation] = field(default_factory=list)
citations: list[Citation] = field(default_factory=list)
truncated: bool = False
2 changes: 0 additions & 2 deletions src/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
class RepositoryError(Exception):
"""Base exception for repository operations."""

pass


class DocumentSaveError(RepositoryError):
"""Raised when a document fails to save to the database."""
Expand Down
2 changes: 0 additions & 2 deletions src/core/interfaces/admin_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ async def clean_all(self) -> None:
Use with caution - this operation is irreversible.
Typically used for testing or resetting the database.
"""
pass

@abstractmethod
async def get_stats(self) -> dict:
Expand All @@ -32,4 +31,3 @@ async def get_stats(self) -> dict:
Returns:
Dict with keys like 'document_count', 'chunk_count', 'storage_bytes'.
"""
pass
11 changes: 5 additions & 6 deletions src/core/interfaces/chunker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any


@dataclass
Expand All @@ -9,17 +9,17 @@ class RawChunk:

content: str
index: int
metadata: Dict[str, Any]
token_count: Optional[int] = None
metadata: dict[str, Any]
token_count: int | None = None


class IChunker(ABC):
"""Interface for document chunking."""

@abstractmethod
async def chunk_document(
self, content: str, title: str, source: str, docling_doc: Optional[Any] = None
) -> List[RawChunk]:
self, content: str, title: str, source: str, docling_doc: Any | None = None
) -> list[RawChunk]:
"""
Split a document into chunks.

Expand All @@ -32,4 +32,3 @@ async def chunk_document(
Returns:
List of RawChunk objects.
"""
pass
7 changes: 2 additions & 5 deletions src/core/interfaces/embedder.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
from abc import ABC, abstractmethod
from typing import List


class IEmbedder(ABC):
"""Interface for embedding generation providers."""

@abstractmethod
async def get_embedding(self, text: str) -> List[float]:
async def get_embedding(self, text: str) -> list[float]:
"""Generate embedding for a single string."""
pass

@abstractmethod
async def get_embeddings(self, texts: List[str]) -> List[List[float]]:
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings for a batch of strings."""
pass
4 changes: 2 additions & 2 deletions src/core/interfaces/llm.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
from typing import Any


@dataclass
Expand All @@ -27,7 +28,6 @@ async def generate_response(
self, system_prompt: str, user_prompt: str, stream: bool = False
) -> AsyncIterator[str] | str:
"""Generate a response from the LLM."""
pass

def supports_tools(self) -> bool:
"""Check if the provider supports function calling / tools.
Expand Down
5 changes: 2 additions & 3 deletions src/core/interfaces/parser.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from abc import ABC, abstractmethod
from typing import Any, Optional
from typing import Any


class IParser(ABC):
"""Interface for document parsing."""

@abstractmethod
async def parse(self, file_path: str) -> tuple[str, Optional[Any]]:
async def parse(self, file_path: str) -> tuple[str, Any | None]:
"""
Parse a document and return its content as markdown and an optional raw document object.

Expand All @@ -16,4 +16,3 @@ async def parse(self, file_path: str) -> tuple[str, Optional[Any]]:
Returns:
Tuple of (markdown_content, raw_document).
"""
pass
Loading
Loading