diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..53d4f84 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +"""Core layer — config, dependencies, exceptions, and shared models.""" \ No newline at end of file diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..feb1845 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,39 @@ +"""Application configuration loaded from .env via pydantic-settings.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """All environment variables with defaults and validation.""" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + # OpenAI + openai_api_key: str + embedding_model: str = "text-embedding-3-small" + llm_model: str = "gpt-4o-mini" + + # Qdrant + qdrant_url: str = "http://localhost:6333" + qdrant_api_key: str | None = None + + # Retrieval + top_k: int = 5 + + # LLM generation + temperature: float = 0.0 + max_tokens: int = 1024 + + # Chunking defaults + chunk_size: int = 512 + chunk_overlap: int = 50 + + # Semantic chunking + semantic_threshold: float = 0.3 + semantic_min_chunk_size: int = 3 + + # Database + database_url: str = "sqlite:///./data/chunking_benchmark.db" + + +settings = Settings() \ No newline at end of file diff --git a/src/core/dependencies.py b/src/core/dependencies.py new file mode 100644 index 0000000..427eb6d --- /dev/null +++ b/src/core/dependencies.py @@ -0,0 +1,20 @@ +"""Dependency injection singletons for OpenAI and Qdrant clients.""" + +from functools import lru_cache + +from openai import OpenAI +from qdrant_client import QdrantClient + +from src.core.config import settings + + +@lru_cache() +def get_openai_client() -> OpenAI: + """Return a cached OpenAI client singleton.""" + return OpenAI(api_key=settings.openai_api_key) + + +@lru_cache() +def get_qdrant_client() -> QdrantClient: + """Return a cached Qdrant client singleton.""" + return QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key) \ No newline at end of file diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..ae944e6 --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,52 @@ +"""Custom exception classes and FastAPI exception handlers.""" + +from fastapi import Request +from fastapi.responses import JSONResponse + + +class ChunkingError(Exception): + """Base exception for chunking-related errors.""" + + +class StrategyNotFoundError(ChunkingError): + """Raised when an unknown strategy name is requested.""" + + +class DocumentProcessingError(ChunkingError): + """Raised when a document cannot be parsed or processed.""" + + +class EnrichmentError(ChunkingError): + """Raised when the contextual enrichment LLM call fails.""" + + +class EmbeddingError(ChunkingError): + """Raised when the OpenAI embedding API call fails.""" + + +class QdrantError(ChunkingError): + """Raised when a Qdrant operation fails.""" + + +class BenchmarkError(Exception): + """Base exception for benchmarking-related errors.""" + + +class DryRunError(BenchmarkError): + """Raised when a dry-run estimation fails.""" + + +async def chunking_exception_handler(request: Request, exc: ChunkingError) -> JSONResponse: + """Handle all chunking-related errors.""" + return JSONResponse( + status_code=400, + content={"detail": str(exc), "type": type(exc).__name__}, + ) + + +async def benchmark_exception_handler(request: Request, exc: BenchmarkError) -> JSONResponse: + """Handle all benchmarking-related errors.""" + return JSONResponse( + status_code=400, + content={"detail": str(exc), "type": type(exc).__name__}, + ) \ No newline at end of file diff --git a/src/core/models.py b/src/core/models.py new file mode 100644 index 0000000..a1e0a45 --- /dev/null +++ b/src/core/models.py @@ -0,0 +1,59 @@ +"""Shared Pydantic models used across all domains.""" + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class StrategyName(str, Enum): + """Canonical identifiers for each chunking strategy.""" + + CONTEXTUAL_STRUCTURE = "contextual_structure" + PARENT_CHILD = "parent_child" + SEMANTIC = "semantic" + MARKDOWN_STRUCTURE = "markdown_structure" + RECURSIVE = "recursive" + + +class Chunk(BaseModel): + """Unified chunk model returned by all five chunking strategies. + + Strategy-specific fields (parent_id, enriched_content) are nullable + when not applicable to the strategy that produced the chunk. + """ + + document_name: str + chunk_id: str + strategy_name: StrategyName + chunk_index: int + text: str + token_count: int + character_count: int + parent_id: Optional[str] = None + enriched_content: Optional[str] = None + + +class ChunkMetadata(BaseModel): + """Payload stored alongside every chunk vector in Qdrant.""" + + document_name: str + chunk_id: str + strategy_name: StrategyName + chunk_index: int + token_count: int + character_count: int + parent_id: Optional[str] = None + + +def chunk_to_metadata(chunk: Chunk) -> ChunkMetadata: + """Extract metadata payload from a Chunk for Qdrant storage.""" + return ChunkMetadata( + document_name=chunk.document_name, + chunk_id=chunk.chunk_id, + strategy_name=chunk.strategy_name, + chunk_index=chunk.chunk_index, + token_count=chunk.token_count, + character_count=chunk.character_count, + parent_id=chunk.parent_id, + ) \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..59dc158 --- /dev/null +++ b/src/main.py @@ -0,0 +1,43 @@ +"""FastAPI app factory. Composes all domain routers and middleware.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.core.exceptions import ( + ChunkingError, + BenchmarkError, + chunking_exception_handler, + benchmark_exception_handler, +) + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application. + + Mounts domain routers and registers exception handlers. + Routers are imported lazily — domains are added in later phases. + """ + app = FastAPI( + title="RAG Chunking Benchmarker", + description="Benchmark five chunking strategies on regulatory documents. " + "Compare retrieval quality, answer faithfulness, and cost.", + version="0.1.0", + ) + + # CORS — allow all origins for development + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Register exception handlers + app.add_exception_handler(ChunkingError, chunking_exception_handler) + app.add_exception_handler(BenchmarkError, benchmark_exception_handler) + + return app + + +app = create_app() \ No newline at end of file