diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0718c32..7b82b4b 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -7,11 +7,11 @@ # Import core application logic from app.core.vector_store import FaissVectorStore from app.core.retrievers import FaissDBRetriever, Retriever -from app.core.rag_service import RAGService +from app.core.services import RAGService # Import the new files for database and API routes from app.db_setup import create_db_tables -from app.api_endpoints import create_api_router +from app.api.routes import create_api_router # Load environment variables from a .env file load_dotenv() diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0718c32..7b82b4b 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -7,11 +7,11 @@ # Import core application logic from app.core.vector_store import FaissVectorStore from app.core.retrievers import FaissDBRetriever, Retriever -from app.core.rag_service import RAGService +from app.core.services import RAGService # Import the new files for database and API routes from app.db_setup import create_db_tables -from app.api_endpoints import create_api_router +from app.api.routes import create_api_router # Load environment variables from a .env file load_dotenv() diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py deleted file mode 100644 index 93b4bb9..0000000 --- a/ai-hub/app/core/rag_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import List, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy.exc import SQLAlchemyError -import dspy - -from app.core.vector_store import FaissVectorStore -from app.db import models -from app.core.retrievers import Retriever -from app.core.llm_providers import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider - - -class RAGService: - """ - Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. - This class acts as a high-level orchestrator. - """ - def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): - self.vector_store = vector_store - self.retrievers = retrievers - - def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: - """ - Adds a document to both the database and the vector store. - """ - try: - document_db = models.Document( - title=doc_data["title"], - text=doc_data["text"], - source_url=doc_data["source_url"] - ) - db.add(document_db) - db.commit() - db.refresh(document_db) - - faiss_index = self.vector_store.add_document(document_db.text) - - vector_metadata = models.VectorMetadata( - document_id=document_db.id, - faiss_index=faiss_index, - embedding_model="mock_embedder" - ) - db.add(vector_metadata) - db.commit() - print(f"Document with ID {document_db.id} successfully added.") - return document_db.id - except SQLAlchemyError as e: - db.rollback() - print(f"Database error while adding document: {e}") - raise - except Exception as e: - db.rollback() - print(f"An unexpected error occurred: {e}") - raise - - async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: - """ - Generates a response to a user prompt by orchestrating the RAG pipeline. - """ - print(f"Received Prompt: {prompt}") - if not prompt or not prompt.strip(): - raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") - - # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) - llm_provider_instance = get_llm_provider(model) - - # 2. Wrap it in our custom DSPy-compatible provider - dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) - - # 3. Configure DSPy's global settings to use our custom LM - dspy.configure(lm=dspy_llm_provider) - - # 4. Initialize and execute the RAG pipeline - rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) - answer = await rag_pipeline.forward(question=prompt, db=db) - - return answer \ No newline at end of file diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0718c32..7b82b4b 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -7,11 +7,11 @@ # Import core application logic from app.core.vector_store import FaissVectorStore from app.core.retrievers import FaissDBRetriever, Retriever -from app.core.rag_service import RAGService +from app.core.services import RAGService # Import the new files for database and API routes from app.db_setup import create_db_tables -from app.api_endpoints import create_api_router +from app.api.routes import create_api_router # Load environment variables from a .env file load_dotenv() diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py deleted file mode 100644 index 93b4bb9..0000000 --- a/ai-hub/app/core/rag_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import List, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy.exc import SQLAlchemyError -import dspy - -from app.core.vector_store import FaissVectorStore -from app.db import models -from app.core.retrievers import Retriever -from app.core.llm_providers import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider - - -class RAGService: - """ - Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. - This class acts as a high-level orchestrator. - """ - def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): - self.vector_store = vector_store - self.retrievers = retrievers - - def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: - """ - Adds a document to both the database and the vector store. - """ - try: - document_db = models.Document( - title=doc_data["title"], - text=doc_data["text"], - source_url=doc_data["source_url"] - ) - db.add(document_db) - db.commit() - db.refresh(document_db) - - faiss_index = self.vector_store.add_document(document_db.text) - - vector_metadata = models.VectorMetadata( - document_id=document_db.id, - faiss_index=faiss_index, - embedding_model="mock_embedder" - ) - db.add(vector_metadata) - db.commit() - print(f"Document with ID {document_db.id} successfully added.") - return document_db.id - except SQLAlchemyError as e: - db.rollback() - print(f"Database error while adding document: {e}") - raise - except Exception as e: - db.rollback() - print(f"An unexpected error occurred: {e}") - raise - - async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: - """ - Generates a response to a user prompt by orchestrating the RAG pipeline. - """ - print(f"Received Prompt: {prompt}") - if not prompt or not prompt.strip(): - raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") - - # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) - llm_provider_instance = get_llm_provider(model) - - # 2. Wrap it in our custom DSPy-compatible provider - dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) - - # 3. Configure DSPy's global settings to use our custom LM - dspy.configure(lm=dspy_llm_provider) - - # 4. Initialize and execute the RAG pipeline - rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) - answer = await rag_pipeline.forward(question=prompt, db=db) - - return answer \ No newline at end of file diff --git a/ai-hub/app/core/services.py b/ai-hub/app/core/services.py new file mode 100644 index 0000000..93b4bb9 --- /dev/null +++ b/ai-hub/app/core/services.py @@ -0,0 +1,77 @@ +from typing import List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import dspy + +from app.core.vector_store import FaissVectorStore +from app.db import models +from app.core.retrievers import Retriever +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider + + +class RAGService: + """ + Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. + This class acts as a high-level orchestrator. + """ + def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): + self.vector_store = vector_store + self.retrievers = retrievers + + def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: + """ + Adds a document to both the database and the vector store. + """ + try: + document_db = models.Document( + title=doc_data["title"], + text=doc_data["text"], + source_url=doc_data["source_url"] + ) + db.add(document_db) + db.commit() + db.refresh(document_db) + + faiss_index = self.vector_store.add_document(document_db.text) + + vector_metadata = models.VectorMetadata( + document_id=document_db.id, + faiss_index=faiss_index, + embedding_model="mock_embedder" + ) + db.add(vector_metadata) + db.commit() + print(f"Document with ID {document_db.id} successfully added.") + return document_db.id + except SQLAlchemyError as e: + db.rollback() + print(f"Database error while adding document: {e}") + raise + except Exception as e: + db.rollback() + print(f"An unexpected error occurred: {e}") + raise + + async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: + """ + Generates a response to a user prompt by orchestrating the RAG pipeline. + """ + print(f"Received Prompt: {prompt}") + if not prompt or not prompt.strip(): + raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") + + # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) + llm_provider_instance = get_llm_provider(model) + + # 2. Wrap it in our custom DSPy-compatible provider + dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) + + # 3. Configure DSPy's global settings to use our custom LM + dspy.configure(lm=dspy_llm_provider) + + # 4. Initialize and execute the RAG pipeline + rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) + answer = await rag_pipeline.forward(question=prompt, db=db) + + return answer \ No newline at end of file diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0718c32..7b82b4b 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -7,11 +7,11 @@ # Import core application logic from app.core.vector_store import FaissVectorStore from app.core.retrievers import FaissDBRetriever, Retriever -from app.core.rag_service import RAGService +from app.core.services import RAGService # Import the new files for database and API routes from app.db_setup import create_db_tables -from app.api_endpoints import create_api_router +from app.api.routes import create_api_router # Load environment variables from a .env file load_dotenv() diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py deleted file mode 100644 index 93b4bb9..0000000 --- a/ai-hub/app/core/rag_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import List, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy.exc import SQLAlchemyError -import dspy - -from app.core.vector_store import FaissVectorStore -from app.db import models -from app.core.retrievers import Retriever -from app.core.llm_providers import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider - - -class RAGService: - """ - Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. - This class acts as a high-level orchestrator. - """ - def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): - self.vector_store = vector_store - self.retrievers = retrievers - - def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: - """ - Adds a document to both the database and the vector store. - """ - try: - document_db = models.Document( - title=doc_data["title"], - text=doc_data["text"], - source_url=doc_data["source_url"] - ) - db.add(document_db) - db.commit() - db.refresh(document_db) - - faiss_index = self.vector_store.add_document(document_db.text) - - vector_metadata = models.VectorMetadata( - document_id=document_db.id, - faiss_index=faiss_index, - embedding_model="mock_embedder" - ) - db.add(vector_metadata) - db.commit() - print(f"Document with ID {document_db.id} successfully added.") - return document_db.id - except SQLAlchemyError as e: - db.rollback() - print(f"Database error while adding document: {e}") - raise - except Exception as e: - db.rollback() - print(f"An unexpected error occurred: {e}") - raise - - async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: - """ - Generates a response to a user prompt by orchestrating the RAG pipeline. - """ - print(f"Received Prompt: {prompt}") - if not prompt or not prompt.strip(): - raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") - - # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) - llm_provider_instance = get_llm_provider(model) - - # 2. Wrap it in our custom DSPy-compatible provider - dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) - - # 3. Configure DSPy's global settings to use our custom LM - dspy.configure(lm=dspy_llm_provider) - - # 4. Initialize and execute the RAG pipeline - rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) - answer = await rag_pipeline.forward(question=prompt, db=db) - - return answer \ No newline at end of file diff --git a/ai-hub/app/core/services.py b/ai-hub/app/core/services.py new file mode 100644 index 0000000..93b4bb9 --- /dev/null +++ b/ai-hub/app/core/services.py @@ -0,0 +1,77 @@ +from typing import List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import dspy + +from app.core.vector_store import FaissVectorStore +from app.db import models +from app.core.retrievers import Retriever +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider + + +class RAGService: + """ + Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. + This class acts as a high-level orchestrator. + """ + def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): + self.vector_store = vector_store + self.retrievers = retrievers + + def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: + """ + Adds a document to both the database and the vector store. + """ + try: + document_db = models.Document( + title=doc_data["title"], + text=doc_data["text"], + source_url=doc_data["source_url"] + ) + db.add(document_db) + db.commit() + db.refresh(document_db) + + faiss_index = self.vector_store.add_document(document_db.text) + + vector_metadata = models.VectorMetadata( + document_id=document_db.id, + faiss_index=faiss_index, + embedding_model="mock_embedder" + ) + db.add(vector_metadata) + db.commit() + print(f"Document with ID {document_db.id} successfully added.") + return document_db.id + except SQLAlchemyError as e: + db.rollback() + print(f"Database error while adding document: {e}") + raise + except Exception as e: + db.rollback() + print(f"An unexpected error occurred: {e}") + raise + + async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: + """ + Generates a response to a user prompt by orchestrating the RAG pipeline. + """ + print(f"Received Prompt: {prompt}") + if not prompt or not prompt.strip(): + raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") + + # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) + llm_provider_instance = get_llm_provider(model) + + # 2. Wrap it in our custom DSPy-compatible provider + dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) + + # 3. Configure DSPy's global settings to use our custom LM + dspy.configure(lm=dspy_llm_provider) + + # 4. Initialize and execute the RAG pipeline + rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) + answer = await rag_pipeline.forward(question=prompt, db=db) + + return answer \ No newline at end of file diff --git a/ai-hub/tests/api/test_routes.py b/ai-hub/tests/api/test_routes.py new file mode 100644 index 0000000..b98fdbf --- /dev/null +++ b/ai-hub/tests/api/test_routes.py @@ -0,0 +1,151 @@ +# tests/api/test_routes.py + +import pytest +from unittest.mock import MagicMock, AsyncMock +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +# Import the dependencies and router factory +from app.core.services import RAGService +from app.db_setup import get_db +from app.api.routes import create_api_router + +@pytest.fixture +def client(): + """ + Pytest fixture to create a TestClient with a fully mocked environment. + + This fixture creates a new, isolated FastAPI app for each test, + ensuring that mocks for the RAGService and database are always used. + """ + # 1. Create a fresh FastAPI app for this test run to prevent state leakage. + test_app = FastAPI() + + # 2. Mock the RAGService and the database session. + mock_rag_service = MagicMock(spec=RAGService) + mock_db_session = MagicMock(spec=Session) + + def override_get_db(): + """Dependency override for the database session.""" + yield mock_db_session + + # 3. Create the API router using the MOCKED service instance. + api_router = create_api_router(rag_service=mock_rag_service) + + # 4. Apply the dependency override and the router to the isolated test app. + test_app.dependency_overrides[get_db] = override_get_db + test_app.include_router(api_router) + + # 5. Yield the client and the mock service for use in the tests. + yield TestClient(test_app), mock_rag_service + + +# --- Test Cases --- + +def test_read_root(client): + """ + Tests the root endpoint to ensure the API is running. + """ + test_client, _ = client + response = test_client.get("/") + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +def test_chat_handler_success(client): + """ + Tests a successful chat request. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock service to return a successful async response. + mock_rag_service.chat_with_rag = AsyncMock(return_value="This is a mocked RAG response.") + + # Act + response = test_client.post("/chat", json={"prompt": "Hello there!", "model": "gemini"}) + + # Assert + assert response.status_code == 200 + assert response.json() == { + "answer": "This is a mocked RAG response.", + "model_used": "gemini" + } + # Verify the mock was called correctly. + mock_rag_service.chat_with_rag.assert_called_once() + + +def test_chat_handler_validation_error(client): + """ + Tests the chat endpoint with invalid data (an empty prompt). + """ + test_client, _ = client + response = test_client.post("/chat", json={"prompt": "", "model": "deepseek"}) + assert response.status_code == 422 # FastAPI's validation error code + +def test_chat_handler_internal_error(client): + """ + Tests the chat endpoint when the RAG service raises an unexpected exception. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to raise an exception. + error_message = "LLM provider is down" + mock_rag_service.chat_with_rag.side_effect = Exception(error_message) + + # Act + response = test_client.post("/chat", json={"prompt": "A valid question", "model": "deepseek"}) + + # Assert + assert response.status_code == 500 + assert f"An unexpected error occurred with the deepseek API: {error_message}" in response.json()["detail"] + +def test_add_document_success(client): + """ + Tests successfully adding a document. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to return a specific document ID. + mock_rag_service.add_document.return_value = 123 + doc_payload = { + "title": "Test Document", + "text": "This is the content of the document.", + "source_url": "http://example.com", + "author": "Tester", + "user_id": "default_user" + } + + # Act + response = test_client.post("/document", json=doc_payload) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": "Document 'Test Document' added successfully with ID 123"} + # Verify the mock was called with the correct data. + mock_rag_service.add_document.assert_called_once_with( + db=mock_rag_service.add_document.call_args.kwargs['db'], + doc_data=doc_payload + ) + +def test_add_document_error(client): + """ + Tests the document creation endpoint when the service raises an exception. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to raise an exception. + error_message = "Database connection failed" + mock_rag_service.add_document.side_effect = Exception(error_message) + # FIX: This payload must be valid to pass Pydantic validation and reach the + # part of the code that handles the exception we are testing for. + doc_payload = { + "title": "Error Doc", + "text": "Some text", + "source_url": "http://example.com/error", + "author": "Error Author", + "user_id": "error_user" + } + + + # Act + response = test_client.post("/document", json=doc_payload) + + # Assert + assert response.status_code == 500 + assert error_message in response.json()["detail"] diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0718c32..7b82b4b 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -7,11 +7,11 @@ # Import core application logic from app.core.vector_store import FaissVectorStore from app.core.retrievers import FaissDBRetriever, Retriever -from app.core.rag_service import RAGService +from app.core.services import RAGService # Import the new files for database and API routes from app.db_setup import create_db_tables -from app.api_endpoints import create_api_router +from app.api.routes import create_api_router # Load environment variables from a .env file load_dotenv() diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py deleted file mode 100644 index 93b4bb9..0000000 --- a/ai-hub/app/core/rag_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import List, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy.exc import SQLAlchemyError -import dspy - -from app.core.vector_store import FaissVectorStore -from app.db import models -from app.core.retrievers import Retriever -from app.core.llm_providers import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider - - -class RAGService: - """ - Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. - This class acts as a high-level orchestrator. - """ - def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): - self.vector_store = vector_store - self.retrievers = retrievers - - def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: - """ - Adds a document to both the database and the vector store. - """ - try: - document_db = models.Document( - title=doc_data["title"], - text=doc_data["text"], - source_url=doc_data["source_url"] - ) - db.add(document_db) - db.commit() - db.refresh(document_db) - - faiss_index = self.vector_store.add_document(document_db.text) - - vector_metadata = models.VectorMetadata( - document_id=document_db.id, - faiss_index=faiss_index, - embedding_model="mock_embedder" - ) - db.add(vector_metadata) - db.commit() - print(f"Document with ID {document_db.id} successfully added.") - return document_db.id - except SQLAlchemyError as e: - db.rollback() - print(f"Database error while adding document: {e}") - raise - except Exception as e: - db.rollback() - print(f"An unexpected error occurred: {e}") - raise - - async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: - """ - Generates a response to a user prompt by orchestrating the RAG pipeline. - """ - print(f"Received Prompt: {prompt}") - if not prompt or not prompt.strip(): - raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") - - # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) - llm_provider_instance = get_llm_provider(model) - - # 2. Wrap it in our custom DSPy-compatible provider - dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) - - # 3. Configure DSPy's global settings to use our custom LM - dspy.configure(lm=dspy_llm_provider) - - # 4. Initialize and execute the RAG pipeline - rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) - answer = await rag_pipeline.forward(question=prompt, db=db) - - return answer \ No newline at end of file diff --git a/ai-hub/app/core/services.py b/ai-hub/app/core/services.py new file mode 100644 index 0000000..93b4bb9 --- /dev/null +++ b/ai-hub/app/core/services.py @@ -0,0 +1,77 @@ +from typing import List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import dspy + +from app.core.vector_store import FaissVectorStore +from app.db import models +from app.core.retrievers import Retriever +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider + + +class RAGService: + """ + Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. + This class acts as a high-level orchestrator. + """ + def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): + self.vector_store = vector_store + self.retrievers = retrievers + + def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: + """ + Adds a document to both the database and the vector store. + """ + try: + document_db = models.Document( + title=doc_data["title"], + text=doc_data["text"], + source_url=doc_data["source_url"] + ) + db.add(document_db) + db.commit() + db.refresh(document_db) + + faiss_index = self.vector_store.add_document(document_db.text) + + vector_metadata = models.VectorMetadata( + document_id=document_db.id, + faiss_index=faiss_index, + embedding_model="mock_embedder" + ) + db.add(vector_metadata) + db.commit() + print(f"Document with ID {document_db.id} successfully added.") + return document_db.id + except SQLAlchemyError as e: + db.rollback() + print(f"Database error while adding document: {e}") + raise + except Exception as e: + db.rollback() + print(f"An unexpected error occurred: {e}") + raise + + async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: + """ + Generates a response to a user prompt by orchestrating the RAG pipeline. + """ + print(f"Received Prompt: {prompt}") + if not prompt or not prompt.strip(): + raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") + + # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) + llm_provider_instance = get_llm_provider(model) + + # 2. Wrap it in our custom DSPy-compatible provider + dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) + + # 3. Configure DSPy's global settings to use our custom LM + dspy.configure(lm=dspy_llm_provider) + + # 4. Initialize and execute the RAG pipeline + rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) + answer = await rag_pipeline.forward(question=prompt, db=db) + + return answer \ No newline at end of file diff --git a/ai-hub/tests/api/test_routes.py b/ai-hub/tests/api/test_routes.py new file mode 100644 index 0000000..b98fdbf --- /dev/null +++ b/ai-hub/tests/api/test_routes.py @@ -0,0 +1,151 @@ +# tests/api/test_routes.py + +import pytest +from unittest.mock import MagicMock, AsyncMock +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +# Import the dependencies and router factory +from app.core.services import RAGService +from app.db_setup import get_db +from app.api.routes import create_api_router + +@pytest.fixture +def client(): + """ + Pytest fixture to create a TestClient with a fully mocked environment. + + This fixture creates a new, isolated FastAPI app for each test, + ensuring that mocks for the RAGService and database are always used. + """ + # 1. Create a fresh FastAPI app for this test run to prevent state leakage. + test_app = FastAPI() + + # 2. Mock the RAGService and the database session. + mock_rag_service = MagicMock(spec=RAGService) + mock_db_session = MagicMock(spec=Session) + + def override_get_db(): + """Dependency override for the database session.""" + yield mock_db_session + + # 3. Create the API router using the MOCKED service instance. + api_router = create_api_router(rag_service=mock_rag_service) + + # 4. Apply the dependency override and the router to the isolated test app. + test_app.dependency_overrides[get_db] = override_get_db + test_app.include_router(api_router) + + # 5. Yield the client and the mock service for use in the tests. + yield TestClient(test_app), mock_rag_service + + +# --- Test Cases --- + +def test_read_root(client): + """ + Tests the root endpoint to ensure the API is running. + """ + test_client, _ = client + response = test_client.get("/") + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +def test_chat_handler_success(client): + """ + Tests a successful chat request. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock service to return a successful async response. + mock_rag_service.chat_with_rag = AsyncMock(return_value="This is a mocked RAG response.") + + # Act + response = test_client.post("/chat", json={"prompt": "Hello there!", "model": "gemini"}) + + # Assert + assert response.status_code == 200 + assert response.json() == { + "answer": "This is a mocked RAG response.", + "model_used": "gemini" + } + # Verify the mock was called correctly. + mock_rag_service.chat_with_rag.assert_called_once() + + +def test_chat_handler_validation_error(client): + """ + Tests the chat endpoint with invalid data (an empty prompt). + """ + test_client, _ = client + response = test_client.post("/chat", json={"prompt": "", "model": "deepseek"}) + assert response.status_code == 422 # FastAPI's validation error code + +def test_chat_handler_internal_error(client): + """ + Tests the chat endpoint when the RAG service raises an unexpected exception. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to raise an exception. + error_message = "LLM provider is down" + mock_rag_service.chat_with_rag.side_effect = Exception(error_message) + + # Act + response = test_client.post("/chat", json={"prompt": "A valid question", "model": "deepseek"}) + + # Assert + assert response.status_code == 500 + assert f"An unexpected error occurred with the deepseek API: {error_message}" in response.json()["detail"] + +def test_add_document_success(client): + """ + Tests successfully adding a document. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to return a specific document ID. + mock_rag_service.add_document.return_value = 123 + doc_payload = { + "title": "Test Document", + "text": "This is the content of the document.", + "source_url": "http://example.com", + "author": "Tester", + "user_id": "default_user" + } + + # Act + response = test_client.post("/document", json=doc_payload) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": "Document 'Test Document' added successfully with ID 123"} + # Verify the mock was called with the correct data. + mock_rag_service.add_document.assert_called_once_with( + db=mock_rag_service.add_document.call_args.kwargs['db'], + doc_data=doc_payload + ) + +def test_add_document_error(client): + """ + Tests the document creation endpoint when the service raises an exception. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to raise an exception. + error_message = "Database connection failed" + mock_rag_service.add_document.side_effect = Exception(error_message) + # FIX: This payload must be valid to pass Pydantic validation and reach the + # part of the code that handles the exception we are testing for. + doc_payload = { + "title": "Error Doc", + "text": "Some text", + "source_url": "http://example.com/error", + "author": "Error Author", + "user_id": "error_user" + } + + + # Act + response = test_client.post("/document", json=doc_payload) + + # Assert + assert response.status_code == 500 + assert error_message in response.json()["detail"] diff --git a/ai-hub/tests/core/test_rag_service.py b/ai-hub/tests/core/test_rag_service.py deleted file mode 100644 index bdd4d17..0000000 --- a/ai-hub/tests/core/test_rag_service.py +++ /dev/null @@ -1,60 +0,0 @@ -import asyncio -from unittest.mock import patch, MagicMock, AsyncMock -from sqlalchemy.orm import Session - -# Import the service being tested -from app.core.rag_service import RAGService - -# Import dependencies that need to be referenced in mocks -from app.core.retrievers import Retriever -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -from app.core.llm_providers import LLMProvider - - -@patch('app.core.rag_service.get_llm_provider') -@patch('app.core.rag_service.DspyRagPipeline') # Patched the new class name -@patch('dspy.configure') -def test_rag_service_orchestration(mock_configure, mock_dspy_pipeline, mock_get_llm_provider): - """ - Tests that RAGService.chat_with_rag correctly orchestrates its dependencies. - It should: - 1. Get the correct LLM provider. - 2. Configure DSPy with a wrapped provider. - 3. Instantiate and call the pipeline with the correct arguments. - """ - # --- Arrange --- - # Mock the dependencies that RAGService uses - mock_llm_provider = MagicMock(spec=LLMProvider) - mock_get_llm_provider.return_value = mock_llm_provider - mock_db = MagicMock(spec=Session) - mock_retriever = MagicMock(spec=Retriever) - - # Mock the pipeline instance and its return value - mock_pipeline_instance = MagicMock(spec=DspyRagPipeline) - mock_pipeline_instance.forward = AsyncMock(return_value="Final RAG response") - mock_dspy_pipeline.return_value = mock_pipeline_instance - - # Instantiate the service class we are testing - rag_service = RAGService(vector_store=MagicMock(), retrievers=[mock_retriever]) - prompt = "Test prompt." - model = "deepseek" - - # --- Act --- - response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model=model)) - - # --- Assert --- - # 1. Assert that the correct LLM provider was requested - mock_get_llm_provider.assert_called_once_with(model) - - # 2. Assert that dspy was configured with a correctly wrapped provider - mock_configure.assert_called_once() - lm_instance = mock_configure.call_args.kwargs['lm'] - assert isinstance(lm_instance, DSPyLLMProvider) - assert lm_instance.provider == mock_llm_provider - - # 3. Assert that the pipeline was instantiated and called correctly - mock_dspy_pipeline.assert_called_once_with(retrievers=[mock_retriever]) - mock_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) - - # 4. Assert the final response is returned - assert response_text == "Final RAG response" \ No newline at end of file diff --git a/ai-hub/app/api/__init__.py b/ai-hub/app/api/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/api/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/api/routes.py b/ai-hub/app/api/routes.py new file mode 100644 index 0000000..6cf3deb --- /dev/null +++ b/ai-hub/app/api/routes.py @@ -0,0 +1,85 @@ +# app/api/routes.py + +from fastapi import APIRouter, HTTPException, Query, Depends +from pydantic import BaseModel, Field # Import Field here +from typing import Literal +from sqlalchemy.orm import Session +from app.core.services import RAGService +from app.db_setup import get_db + +# Pydantic Models for API requests +class ChatRequest(BaseModel): + # Added min_length to ensure the prompt is not an empty string + prompt: str = Field(..., min_length=1) + # This ensures the 'model' field must be either "deepseek" or "gemini". + model: Literal["deepseek", "gemini"] + +class DocumentCreate(BaseModel): + title: str + text: str + source_url: str = None + author: str = None + user_id: str = "default_user" + +def create_api_router(rag_service: RAGService) -> APIRouter: + """ + Creates and returns an APIRouter with all the application's endpoints. + + This function takes the RAGService instance as an argument, so it can be + injected from the main application factory. + """ + router = APIRouter() + + @router.get("/") + def read_root(): + return {"status": "AI Model Hub is running!"} + + @router.post("/chat", status_code=200) + async def chat_handler( + request: ChatRequest, + db: Session = Depends(get_db) + ): + """ + Handles a chat request, using the prompt and model specified in the request body. + """ + try: + # Both prompt and model are now accessed from the single request object + response_text = await rag_service.chat_with_rag( + db=db, + prompt=request.prompt, + model=request.model + ) + return {"answer": response_text, "model_used": request.model} + + except ValueError as e: + # This error is raised if the model is unsupported or the prompt is invalid. + # 422 is a more specific code for a validation failure on the request data. + raise HTTPException( + status_code=422, + detail=str(e) + ) + + except Exception as e: + # This catches all other potential errors during the API call. + raise HTTPException( + status_code=500, + detail=f"An unexpected error occurred with the {request.model} API: {e}" + ) + + @router.post("/document") + async def add_document( + doc: DocumentCreate, + db: Session = Depends(get_db) + ): + """ + Adds a new document to the database and its vector embedding to the FAISS index. + """ + try: + doc_data = doc.model_dump() + document_id = rag_service.add_document(db=db, doc_data=doc_data) + + return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {e}") + + return router diff --git a/ai-hub/app/api_endpoints.py b/ai-hub/app/api_endpoints.py deleted file mode 100644 index 04ec0c1..0000000 --- a/ai-hub/app/api_endpoints.py +++ /dev/null @@ -1,83 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query, Depends -from pydantic import BaseModel, Field # Import Field here -from typing import Literal -from sqlalchemy.orm import Session -from app.core.rag_service import RAGService -from app.db_setup import get_db - -# Pydantic Models for API requests -class ChatRequest(BaseModel): - # Added min_length to ensure the prompt is not an empty string - prompt: str = Field(..., min_length=1) - # This ensures the 'model' field must be either "deepseek" or "gemini". - model: Literal["deepseek", "gemini"] - -class DocumentCreate(BaseModel): - title: str - text: str - source_url: str = None - author: str = None - user_id: str = "default_user" - -def create_api_router(rag_service: RAGService) -> APIRouter: - """ - Creates and returns an APIRouter with all the application's endpoints. - - This function takes the RAGService instance as an argument, so it can be - injected from the main application factory. - """ - router = APIRouter() - - @router.get("/") - def read_root(): - return {"status": "AI Model Hub is running!"} - - @router.post("/chat", status_code=200) - async def chat_handler( - request: ChatRequest, - db: Session = Depends(get_db) - ): - """ - Handles a chat request, using the prompt and model specified in the request body. - """ - try: - # Both prompt and model are now accessed from the single request object - response_text = await rag_service.chat_with_rag( - db=db, - prompt=request.prompt, - model=request.model - ) - return {"answer": response_text, "model_used": request.model} - - except ValueError as e: - # This error is raised if the model is unsupported or the prompt is invalid. - # 422 is a more specific code for a validation failure on the request data. - raise HTTPException( - status_code=422, - detail=str(e) - ) - - except Exception as e: - # This catches all other potential errors during the API call. - raise HTTPException( - status_code=500, - detail=f"An unexpected error occurred with the {request.model} API: {e}" - ) - - @router.post("/document") - async def add_document( - doc: DocumentCreate, - db: Session = Depends(get_db) - ): - """ - Adds a new document to the database and its vector embedding to the FAISS index. - """ - try: - doc_data = doc.model_dump() - document_id = rag_service.add_document(db=db, doc_data=doc_data) - - return {"message": f"Document '{doc.title}' added successfully with ID {document_id}"} - except Exception as e: - raise HTTPException(status_code=500, detail=f"An error occurred: {e}") - - return router diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 0718c32..7b82b4b 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -7,11 +7,11 @@ # Import core application logic from app.core.vector_store import FaissVectorStore from app.core.retrievers import FaissDBRetriever, Retriever -from app.core.rag_service import RAGService +from app.core.services import RAGService # Import the new files for database and API routes from app.db_setup import create_db_tables -from app.api_endpoints import create_api_router +from app.api.routes import create_api_router # Load environment variables from a .env file load_dotenv() diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py deleted file mode 100644 index 93b4bb9..0000000 --- a/ai-hub/app/core/rag_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import List, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy.exc import SQLAlchemyError -import dspy - -from app.core.vector_store import FaissVectorStore -from app.db import models -from app.core.retrievers import Retriever -from app.core.llm_providers import get_llm_provider -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider - - -class RAGService: - """ - Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. - This class acts as a high-level orchestrator. - """ - def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): - self.vector_store = vector_store - self.retrievers = retrievers - - def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: - """ - Adds a document to both the database and the vector store. - """ - try: - document_db = models.Document( - title=doc_data["title"], - text=doc_data["text"], - source_url=doc_data["source_url"] - ) - db.add(document_db) - db.commit() - db.refresh(document_db) - - faiss_index = self.vector_store.add_document(document_db.text) - - vector_metadata = models.VectorMetadata( - document_id=document_db.id, - faiss_index=faiss_index, - embedding_model="mock_embedder" - ) - db.add(vector_metadata) - db.commit() - print(f"Document with ID {document_db.id} successfully added.") - return document_db.id - except SQLAlchemyError as e: - db.rollback() - print(f"Database error while adding document: {e}") - raise - except Exception as e: - db.rollback() - print(f"An unexpected error occurred: {e}") - raise - - async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: - """ - Generates a response to a user prompt by orchestrating the RAG pipeline. - """ - print(f"Received Prompt: {prompt}") - if not prompt or not prompt.strip(): - raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") - - # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) - llm_provider_instance = get_llm_provider(model) - - # 2. Wrap it in our custom DSPy-compatible provider - dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) - - # 3. Configure DSPy's global settings to use our custom LM - dspy.configure(lm=dspy_llm_provider) - - # 4. Initialize and execute the RAG pipeline - rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) - answer = await rag_pipeline.forward(question=prompt, db=db) - - return answer \ No newline at end of file diff --git a/ai-hub/app/core/services.py b/ai-hub/app/core/services.py new file mode 100644 index 0000000..93b4bb9 --- /dev/null +++ b/ai-hub/app/core/services.py @@ -0,0 +1,77 @@ +from typing import List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import dspy + +from app.core.vector_store import FaissVectorStore +from app.db import models +from app.core.retrievers import Retriever +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider + + +class RAGService: + """ + Service class for managing the RAG (Retrieval-Augmented Generation) pipeline. + This class acts as a high-level orchestrator. + """ + def __init__(self, vector_store: FaissVectorStore, retrievers: List[Retriever]): + self.vector_store = vector_store + self.retrievers = retrievers + + def add_document(self, db: Session, doc_data: Dict[str, Any]) -> int: + """ + Adds a document to both the database and the vector store. + """ + try: + document_db = models.Document( + title=doc_data["title"], + text=doc_data["text"], + source_url=doc_data["source_url"] + ) + db.add(document_db) + db.commit() + db.refresh(document_db) + + faiss_index = self.vector_store.add_document(document_db.text) + + vector_metadata = models.VectorMetadata( + document_id=document_db.id, + faiss_index=faiss_index, + embedding_model="mock_embedder" + ) + db.add(vector_metadata) + db.commit() + print(f"Document with ID {document_db.id} successfully added.") + return document_db.id + except SQLAlchemyError as e: + db.rollback() + print(f"Database error while adding document: {e}") + raise + except Exception as e: + db.rollback() + print(f"An unexpected error occurred: {e}") + raise + + async def chat_with_rag(self, db: Session, prompt: str, model: str) -> str: + """ + Generates a response to a user prompt by orchestrating the RAG pipeline. + """ + print(f"Received Prompt: {prompt}") + if not prompt or not prompt.strip(): + raise ValueError("The prompt cannot be null, empty, or contain only whitespace.") + + # 1. Get the underlying LLM provider (e.g., Gemini, DeepSeek) + llm_provider_instance = get_llm_provider(model) + + # 2. Wrap it in our custom DSPy-compatible provider + dspy_llm_provider = DSPyLLMProvider(provider=llm_provider_instance, model_name=model) + + # 3. Configure DSPy's global settings to use our custom LM + dspy.configure(lm=dspy_llm_provider) + + # 4. Initialize and execute the RAG pipeline + rag_pipeline = DspyRagPipeline(retrievers=self.retrievers) + answer = await rag_pipeline.forward(question=prompt, db=db) + + return answer \ No newline at end of file diff --git a/ai-hub/tests/api/test_routes.py b/ai-hub/tests/api/test_routes.py new file mode 100644 index 0000000..b98fdbf --- /dev/null +++ b/ai-hub/tests/api/test_routes.py @@ -0,0 +1,151 @@ +# tests/api/test_routes.py + +import pytest +from unittest.mock import MagicMock, AsyncMock +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +# Import the dependencies and router factory +from app.core.services import RAGService +from app.db_setup import get_db +from app.api.routes import create_api_router + +@pytest.fixture +def client(): + """ + Pytest fixture to create a TestClient with a fully mocked environment. + + This fixture creates a new, isolated FastAPI app for each test, + ensuring that mocks for the RAGService and database are always used. + """ + # 1. Create a fresh FastAPI app for this test run to prevent state leakage. + test_app = FastAPI() + + # 2. Mock the RAGService and the database session. + mock_rag_service = MagicMock(spec=RAGService) + mock_db_session = MagicMock(spec=Session) + + def override_get_db(): + """Dependency override for the database session.""" + yield mock_db_session + + # 3. Create the API router using the MOCKED service instance. + api_router = create_api_router(rag_service=mock_rag_service) + + # 4. Apply the dependency override and the router to the isolated test app. + test_app.dependency_overrides[get_db] = override_get_db + test_app.include_router(api_router) + + # 5. Yield the client and the mock service for use in the tests. + yield TestClient(test_app), mock_rag_service + + +# --- Test Cases --- + +def test_read_root(client): + """ + Tests the root endpoint to ensure the API is running. + """ + test_client, _ = client + response = test_client.get("/") + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +def test_chat_handler_success(client): + """ + Tests a successful chat request. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock service to return a successful async response. + mock_rag_service.chat_with_rag = AsyncMock(return_value="This is a mocked RAG response.") + + # Act + response = test_client.post("/chat", json={"prompt": "Hello there!", "model": "gemini"}) + + # Assert + assert response.status_code == 200 + assert response.json() == { + "answer": "This is a mocked RAG response.", + "model_used": "gemini" + } + # Verify the mock was called correctly. + mock_rag_service.chat_with_rag.assert_called_once() + + +def test_chat_handler_validation_error(client): + """ + Tests the chat endpoint with invalid data (an empty prompt). + """ + test_client, _ = client + response = test_client.post("/chat", json={"prompt": "", "model": "deepseek"}) + assert response.status_code == 422 # FastAPI's validation error code + +def test_chat_handler_internal_error(client): + """ + Tests the chat endpoint when the RAG service raises an unexpected exception. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to raise an exception. + error_message = "LLM provider is down" + mock_rag_service.chat_with_rag.side_effect = Exception(error_message) + + # Act + response = test_client.post("/chat", json={"prompt": "A valid question", "model": "deepseek"}) + + # Assert + assert response.status_code == 500 + assert f"An unexpected error occurred with the deepseek API: {error_message}" in response.json()["detail"] + +def test_add_document_success(client): + """ + Tests successfully adding a document. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to return a specific document ID. + mock_rag_service.add_document.return_value = 123 + doc_payload = { + "title": "Test Document", + "text": "This is the content of the document.", + "source_url": "http://example.com", + "author": "Tester", + "user_id": "default_user" + } + + # Act + response = test_client.post("/document", json=doc_payload) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": "Document 'Test Document' added successfully with ID 123"} + # Verify the mock was called with the correct data. + mock_rag_service.add_document.assert_called_once_with( + db=mock_rag_service.add_document.call_args.kwargs['db'], + doc_data=doc_payload + ) + +def test_add_document_error(client): + """ + Tests the document creation endpoint when the service raises an exception. + """ + test_client, mock_rag_service = client + # Arrange: Configure the mock to raise an exception. + error_message = "Database connection failed" + mock_rag_service.add_document.side_effect = Exception(error_message) + # FIX: This payload must be valid to pass Pydantic validation and reach the + # part of the code that handles the exception we are testing for. + doc_payload = { + "title": "Error Doc", + "text": "Some text", + "source_url": "http://example.com/error", + "author": "Error Author", + "user_id": "error_user" + } + + + # Act + response = test_client.post("/document", json=doc_payload) + + # Assert + assert response.status_code == 500 + assert error_message in response.json()["detail"] diff --git a/ai-hub/tests/core/test_rag_service.py b/ai-hub/tests/core/test_rag_service.py deleted file mode 100644 index bdd4d17..0000000 --- a/ai-hub/tests/core/test_rag_service.py +++ /dev/null @@ -1,60 +0,0 @@ -import asyncio -from unittest.mock import patch, MagicMock, AsyncMock -from sqlalchemy.orm import Session - -# Import the service being tested -from app.core.rag_service import RAGService - -# Import dependencies that need to be referenced in mocks -from app.core.retrievers import Retriever -from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -from app.core.llm_providers import LLMProvider - - -@patch('app.core.rag_service.get_llm_provider') -@patch('app.core.rag_service.DspyRagPipeline') # Patched the new class name -@patch('dspy.configure') -def test_rag_service_orchestration(mock_configure, mock_dspy_pipeline, mock_get_llm_provider): - """ - Tests that RAGService.chat_with_rag correctly orchestrates its dependencies. - It should: - 1. Get the correct LLM provider. - 2. Configure DSPy with a wrapped provider. - 3. Instantiate and call the pipeline with the correct arguments. - """ - # --- Arrange --- - # Mock the dependencies that RAGService uses - mock_llm_provider = MagicMock(spec=LLMProvider) - mock_get_llm_provider.return_value = mock_llm_provider - mock_db = MagicMock(spec=Session) - mock_retriever = MagicMock(spec=Retriever) - - # Mock the pipeline instance and its return value - mock_pipeline_instance = MagicMock(spec=DspyRagPipeline) - mock_pipeline_instance.forward = AsyncMock(return_value="Final RAG response") - mock_dspy_pipeline.return_value = mock_pipeline_instance - - # Instantiate the service class we are testing - rag_service = RAGService(vector_store=MagicMock(), retrievers=[mock_retriever]) - prompt = "Test prompt." - model = "deepseek" - - # --- Act --- - response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model=model)) - - # --- Assert --- - # 1. Assert that the correct LLM provider was requested - mock_get_llm_provider.assert_called_once_with(model) - - # 2. Assert that dspy was configured with a correctly wrapped provider - mock_configure.assert_called_once() - lm_instance = mock_configure.call_args.kwargs['lm'] - assert isinstance(lm_instance, DSPyLLMProvider) - assert lm_instance.provider == mock_llm_provider - - # 3. Assert that the pipeline was instantiated and called correctly - mock_dspy_pipeline.assert_called_once_with(retrievers=[mock_retriever]) - mock_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) - - # 4. Assert the final response is returned - assert response_text == "Final RAG response" \ No newline at end of file diff --git a/ai-hub/tests/core/test_services.py b/ai-hub/tests/core/test_services.py new file mode 100644 index 0000000..44b567a --- /dev/null +++ b/ai-hub/tests/core/test_services.py @@ -0,0 +1,60 @@ +import asyncio +from unittest.mock import patch, MagicMock, AsyncMock +from sqlalchemy.orm import Session + +# Import the service being tested +from app.core.services import RAGService + +# Import dependencies that need to be referenced in mocks +from app.core.retrievers import Retriever +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider +from app.core.llm_providers import LLMProvider + + +@patch('app.core.services.get_llm_provider') +@patch('app.core.services.DspyRagPipeline') # Patched the new class name +@patch('dspy.configure') +def test_rag_service_orchestration(mock_configure, mock_dspy_pipeline, mock_get_llm_provider): + """ + Tests that RAGService.chat_with_rag correctly orchestrates its dependencies. + It should: + 1. Get the correct LLM provider. + 2. Configure DSPy with a wrapped provider. + 3. Instantiate and call the pipeline with the correct arguments. + """ + # --- Arrange --- + # Mock the dependencies that RAGService uses + mock_llm_provider = MagicMock(spec=LLMProvider) + mock_get_llm_provider.return_value = mock_llm_provider + mock_db = MagicMock(spec=Session) + mock_retriever = MagicMock(spec=Retriever) + + # Mock the pipeline instance and its return value + mock_pipeline_instance = MagicMock(spec=DspyRagPipeline) + mock_pipeline_instance.forward = AsyncMock(return_value="Final RAG response") + mock_dspy_pipeline.return_value = mock_pipeline_instance + + # Instantiate the service class we are testing + rag_service = RAGService(vector_store=MagicMock(), retrievers=[mock_retriever]) + prompt = "Test prompt." + model = "deepseek" + + # --- Act --- + response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model=model)) + + # --- Assert --- + # 1. Assert that the correct LLM provider was requested + mock_get_llm_provider.assert_called_once_with(model) + + # 2. Assert that dspy was configured with a correctly wrapped provider + mock_configure.assert_called_once() + lm_instance = mock_configure.call_args.kwargs['lm'] + assert isinstance(lm_instance, DSPyLLMProvider) + assert lm_instance.provider == mock_llm_provider + + # 3. Assert that the pipeline was instantiated and called correctly + mock_dspy_pipeline.assert_called_once_with(retrievers=[mock_retriever]) + mock_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) + + # 4. Assert the final response is returned + assert response_text == "Final RAG response" \ No newline at end of file