feat(core): add core layer with config, clients, exceptions, models, and app factory
This commit is contained in:
1
src/core/__init__.py
Normal file
1
src/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core layer — config, dependencies, exceptions, and shared models."""
|
||||
39
src/core/config.py
Normal file
39
src/core/config.py
Normal file
@@ -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()
|
||||
20
src/core/dependencies.py
Normal file
20
src/core/dependencies.py
Normal file
@@ -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)
|
||||
52
src/core/exceptions.py
Normal file
52
src/core/exceptions.py
Normal file
@@ -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__},
|
||||
)
|
||||
59
src/core/models.py
Normal file
59
src/core/models.py
Normal file
@@ -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,
|
||||
)
|
||||
43
src/main.py
Normal file
43
src/main.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user