diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/ai-hub/app/db_setup.py b/ai-hub/app/db_setup.py index 739ebd2..63a64a6 100644 --- a/ai-hub/app/db_setup.py +++ b/ai-hub/app/db_setup.py @@ -11,7 +11,7 @@ # This configuration allows for easy switching between SQLite and PostgreSQL. DB_MODE = os.getenv("DB_MODE", "sqlite") if DB_MODE == "sqlite": - DATABASE_URL = "sqlite:///./ai_hub.db" + DATABASE_URL = "sqlite:///./data/ai_hub.db" # The connect_args are needed for SQLite to work with FastAPI's multiple threads engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/ai-hub/app/db_setup.py b/ai-hub/app/db_setup.py index 739ebd2..63a64a6 100644 --- a/ai-hub/app/db_setup.py +++ b/ai-hub/app/db_setup.py @@ -11,7 +11,7 @@ # This configuration allows for easy switching between SQLite and PostgreSQL. DB_MODE = os.getenv("DB_MODE", "sqlite") if DB_MODE == "sqlite": - DATABASE_URL = "sqlite:///./ai_hub.db" + DATABASE_URL = "sqlite:///./data/ai_hub.db" # The connect_args are needed for SQLite to work with FastAPI's multiple threads engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: diff --git a/ai-hub/tests/core/__init__.py b/ai-hub/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/__init__.py diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/ai-hub/app/db_setup.py b/ai-hub/app/db_setup.py index 739ebd2..63a64a6 100644 --- a/ai-hub/app/db_setup.py +++ b/ai-hub/app/db_setup.py @@ -11,7 +11,7 @@ # This configuration allows for easy switching between SQLite and PostgreSQL. DB_MODE = os.getenv("DB_MODE", "sqlite") if DB_MODE == "sqlite": - DATABASE_URL = "sqlite:///./ai_hub.db" + DATABASE_URL = "sqlite:///./data/ai_hub.db" # The connect_args are needed for SQLite to work with FastAPI's multiple threads engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: diff --git a/ai-hub/tests/core/__init__.py b/ai-hub/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/__init__.py diff --git a/ai-hub/tests/core/pipelines/__init__.py b/ai-hub/tests/core/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/pipelines/__init__.py diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/ai-hub/app/db_setup.py b/ai-hub/app/db_setup.py index 739ebd2..63a64a6 100644 --- a/ai-hub/app/db_setup.py +++ b/ai-hub/app/db_setup.py @@ -11,7 +11,7 @@ # This configuration allows for easy switching between SQLite and PostgreSQL. DB_MODE = os.getenv("DB_MODE", "sqlite") if DB_MODE == "sqlite": - DATABASE_URL = "sqlite:///./ai_hub.db" + DATABASE_URL = "sqlite:///./data/ai_hub.db" # The connect_args are needed for SQLite to work with FastAPI's multiple threads engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: diff --git a/ai-hub/tests/core/__init__.py b/ai-hub/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/__init__.py diff --git a/ai-hub/tests/core/pipelines/__init__.py b/ai-hub/tests/core/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/pipelines/__init__.py diff --git a/ai-hub/tests/core/pipelines/test_dspy_rag.py b/ai-hub/tests/core/pipelines/test_dspy_rag.py new file mode 100644 index 0000000..5e4aa3d --- /dev/null +++ b/ai-hub/tests/core/pipelines/test_dspy_rag.py @@ -0,0 +1,111 @@ +# tests/core/pipelines/test_dspy_rag.py + +import asyncio +from unittest.mock import MagicMock, AsyncMock +from sqlalchemy.orm import Session +import dspy +import pytest + +# Import the pipeline being tested +from app.core.pipelines.dspy_rag import DspyRagPipeline, AnswerWithContext + +# Import its dependencies for mocking +from app.core.retrievers import Retriever + +@pytest.fixture +def mock_lm_configured(): + """ + A pytest fixture to mock the dspy language model and configure it globally + for the duration of a test. + """ + # 1. Create the mock LM object + mock_lm_instance = MagicMock() + # 2. Mock its async `aforward` method to return a dspy-compatible object + mock_lm_instance.aforward = AsyncMock( + return_value=MagicMock( + choices=[MagicMock(message=MagicMock(content="Mocked LLM answer"))] + ) + ) + + # 3. Store the original LM (if any) to restore it after the test + original_lm = dspy.settings.lm + + # 4. CRITICAL FIX: Configure dspy to use our mock LM + dspy.configure(lm=mock_lm_instance) + + # 5. The test runs here, with the mock configured + yield mock_lm_instance + + # 6. After the test, restore the original LM configuration + dspy.configure(lm=original_lm) + + +def test_dspy_rag_pipeline_with_context(mock_lm_configured): + """ + Tests that DspyRagPipeline correctly processes a question when context is found. + """ + # --- Arrange --- + mock_retriever = MagicMock(spec=Retriever) + mock_retriever.retrieve_context.return_value = ["Context chunk 1.", "Context chunk 2."] + mock_db = MagicMock(spec=Session) + + pipeline = DspyRagPipeline(retrievers=[mock_retriever]) + question = "What is the question?" + + # --- Act --- + response = asyncio.run(pipeline.forward(question=question, db=mock_db)) + + # --- Assert --- + # Assert the retriever was called correctly + mock_retriever.retrieve_context.assert_called_once_with(question, mock_db) + + # Assert the language model was called with the correctly constructed prompt + expected_context = "Context chunk 1.\n\nContext chunk 2." + instruction = AnswerWithContext.__doc__ + expected_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {expected_context}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + # The fixture provides the mock_lm_configured object, which is the mock LM + mock_lm_configured.aforward.assert_called_once_with(prompt=expected_prompt) + + # Assert the final answer from the mock is returned + assert response == "Mocked LLM answer" + + +def test_dspy_rag_pipeline_without_context(mock_lm_configured): + """ + Tests that DspyRagPipeline correctly handles the case where no context is found. + """ + # --- Arrange --- + mock_retriever = MagicMock(spec=Retriever) + mock_retriever.retrieve_context.return_value = [] # No context found + mock_db = MagicMock(spec=Session) + + pipeline = DspyRagPipeline(retrievers=[mock_retriever]) + question = "What is the question?" + + # --- Act --- + response = asyncio.run(pipeline.forward(question=question, db=mock_db)) + + # --- Assert --- + # Assert the retriever was called + mock_retriever.retrieve_context.assert_called_once_with(question, mock_db) + + # Assert the LM was called with the placeholder context + expected_context = "No context provided." + instruction = AnswerWithContext.__doc__ + expected_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {expected_context}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + mock_lm_configured.aforward.assert_called_once_with(prompt=expected_prompt) + + # Assert the final answer from the mock is returned + assert response == "Mocked LLM answer" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/ai-hub/app/db_setup.py b/ai-hub/app/db_setup.py index 739ebd2..63a64a6 100644 --- a/ai-hub/app/db_setup.py +++ b/ai-hub/app/db_setup.py @@ -11,7 +11,7 @@ # This configuration allows for easy switching between SQLite and PostgreSQL. DB_MODE = os.getenv("DB_MODE", "sqlite") if DB_MODE == "sqlite": - DATABASE_URL = "sqlite:///./ai_hub.db" + DATABASE_URL = "sqlite:///./data/ai_hub.db" # The connect_args are needed for SQLite to work with FastAPI's multiple threads engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: diff --git a/ai-hub/tests/core/__init__.py b/ai-hub/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/__init__.py diff --git a/ai-hub/tests/core/pipelines/__init__.py b/ai-hub/tests/core/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/pipelines/__init__.py diff --git a/ai-hub/tests/core/pipelines/test_dspy_rag.py b/ai-hub/tests/core/pipelines/test_dspy_rag.py new file mode 100644 index 0000000..5e4aa3d --- /dev/null +++ b/ai-hub/tests/core/pipelines/test_dspy_rag.py @@ -0,0 +1,111 @@ +# tests/core/pipelines/test_dspy_rag.py + +import asyncio +from unittest.mock import MagicMock, AsyncMock +from sqlalchemy.orm import Session +import dspy +import pytest + +# Import the pipeline being tested +from app.core.pipelines.dspy_rag import DspyRagPipeline, AnswerWithContext + +# Import its dependencies for mocking +from app.core.retrievers import Retriever + +@pytest.fixture +def mock_lm_configured(): + """ + A pytest fixture to mock the dspy language model and configure it globally + for the duration of a test. + """ + # 1. Create the mock LM object + mock_lm_instance = MagicMock() + # 2. Mock its async `aforward` method to return a dspy-compatible object + mock_lm_instance.aforward = AsyncMock( + return_value=MagicMock( + choices=[MagicMock(message=MagicMock(content="Mocked LLM answer"))] + ) + ) + + # 3. Store the original LM (if any) to restore it after the test + original_lm = dspy.settings.lm + + # 4. CRITICAL FIX: Configure dspy to use our mock LM + dspy.configure(lm=mock_lm_instance) + + # 5. The test runs here, with the mock configured + yield mock_lm_instance + + # 6. After the test, restore the original LM configuration + dspy.configure(lm=original_lm) + + +def test_dspy_rag_pipeline_with_context(mock_lm_configured): + """ + Tests that DspyRagPipeline correctly processes a question when context is found. + """ + # --- Arrange --- + mock_retriever = MagicMock(spec=Retriever) + mock_retriever.retrieve_context.return_value = ["Context chunk 1.", "Context chunk 2."] + mock_db = MagicMock(spec=Session) + + pipeline = DspyRagPipeline(retrievers=[mock_retriever]) + question = "What is the question?" + + # --- Act --- + response = asyncio.run(pipeline.forward(question=question, db=mock_db)) + + # --- Assert --- + # Assert the retriever was called correctly + mock_retriever.retrieve_context.assert_called_once_with(question, mock_db) + + # Assert the language model was called with the correctly constructed prompt + expected_context = "Context chunk 1.\n\nContext chunk 2." + instruction = AnswerWithContext.__doc__ + expected_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {expected_context}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + # The fixture provides the mock_lm_configured object, which is the mock LM + mock_lm_configured.aforward.assert_called_once_with(prompt=expected_prompt) + + # Assert the final answer from the mock is returned + assert response == "Mocked LLM answer" + + +def test_dspy_rag_pipeline_without_context(mock_lm_configured): + """ + Tests that DspyRagPipeline correctly handles the case where no context is found. + """ + # --- Arrange --- + mock_retriever = MagicMock(spec=Retriever) + mock_retriever.retrieve_context.return_value = [] # No context found + mock_db = MagicMock(spec=Session) + + pipeline = DspyRagPipeline(retrievers=[mock_retriever]) + question = "What is the question?" + + # --- Act --- + response = asyncio.run(pipeline.forward(question=question, db=mock_db)) + + # --- Assert --- + # Assert the retriever was called + mock_retriever.retrieve_context.assert_called_once_with(question, mock_db) + + # Assert the LM was called with the placeholder context + expected_context = "No context provided." + instruction = AnswerWithContext.__doc__ + expected_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {expected_context}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + mock_lm_configured.aforward.assert_called_once_with(prompt=expected_prompt) + + # Assert the final answer from the mock is returned + assert response == "Mocked LLM answer" \ No newline at end of file diff --git a/ai-hub/tests/core/test_rag_service.py b/ai-hub/tests/core/test_rag_service.py index e0ca1a9..bdd4d17 100644 --- a/ai-hub/tests/core/test_rag_service.py +++ b/ai-hub/tests/core/test_rag_service.py @@ -1,96 +1,60 @@ import asyncio -from unittest.mock import patch, MagicMock, AsyncMock, call +from unittest.mock import patch, MagicMock, AsyncMock from sqlalchemy.orm import Session -import dspy -# Import what you are testing -from app.core.rag_service import RAGService, RAGPipeline, DSPyLLMProvider -# Import dependencies that need to be referenced +# 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.llm_providers import LLMProvider # For type checks if needed +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider +from app.core.llm_providers import LLMProvider -# --- RAGService Unit Tests --- -# ... (Your successful add_document tests are fine and don't need changes) ... - -# NOTE: The patch target for get_llm_provider has been corrected. @patch('app.core.rag_service.get_llm_provider') -@patch('app.core.rag_service.RAGPipeline') +@patch('app.core.rag_service.DspyRagPipeline') # Patched the new class name @patch('dspy.configure') -def test_rag_service_chat_with_rag_with_context(mock_configure, mock_rag_pipeline, mock_get_llm_provider): +def test_rag_service_orchestration(mock_configure, mock_dspy_pipeline, mock_get_llm_provider): """ - Test the RAGService.chat_with_rag method when context is retrieved. + 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_retriever.retrieve_context.return_value = ["Context text 1.", "Context text 2."] - mock_rag_pipeline_instance = MagicMock(spec=RAGPipeline) - mock_rag_pipeline_instance.forward = AsyncMock(return_value="LLM response with context") - mock_rag_pipeline.return_value = mock_rag_pipeline_instance + # 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="deepseek")) + response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model=model)) # --- Assert --- - mock_get_llm_provider.assert_called_once_with("deepseek") - + # 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) - # FIX 1: Assert it's an instance of the correct wrapper class. - assert isinstance(lm_instance, DSPyLLMProvider) - # This assertion will now pass because the patch target is correct. - assert lm_instance.provider == mock_llm_provider - - mock_rag_pipeline.assert_called_once_with(retrievers=[mock_retriever]) - mock_rag_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) - assert response_text == "LLM response with context" - - -# NOTE: The patch target for get_llm_provider has been corrected. -@patch('app.core.rag_service.get_llm_provider') -@patch('app.core.rag_service.RAGPipeline') -@patch('dspy.configure') -def test_rag_service_chat_with_rag_without_context(mock_configure, mock_rag_pipeline, mock_get_llm_provider): - """ - Test the RAGService.chat_with_rag method when no context is retrieved. - """ - # --- Arrange --- - mock_db = MagicMock(spec=Session) - mock_llm_provider = MagicMock(spec=LLMProvider) - mock_get_llm_provider.return_value = mock_llm_provider - - mock_retriever = MagicMock(spec=Retriever) - mock_retriever.retrieve_context.return_value = [] - - mock_rag_pipeline_instance = MagicMock(spec=RAGPipeline) - mock_rag_pipeline_instance.forward = AsyncMock(return_value="LLM response without context") - mock_rag_pipeline.return_value = mock_rag_pipeline_instance - - rag_service = RAGService(vector_store=MagicMock(), retrievers=[mock_retriever]) - prompt = "Test prompt without context." - - # --- Act --- - response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model="deepseek")) - - # --- Assert --- - mock_get_llm_provider.assert_called_once_with("deepseek") - - mock_configure.assert_called_once() - lm_instance = mock_configure.call_args.kwargs['lm'] - - assert isinstance(lm_instance, DSPyLLMProvider) - # This assertion will now pass because the patch target is correct. - assert lm_instance.provider == mock_llm_provider - - mock_rag_pipeline.assert_called_once_with(retrievers=[mock_retriever]) - mock_rag_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) - assert response_text == "LLM response without context" \ No newline at end of file + # 4. Assert the final response is returned + assert response_text == "Final RAG response" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e0b2c9..95f61e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .env **/.env **/*.egg-info -faiss_index.bin -ai_hub.db \ No newline at end of file +**/faiss_index.bin +**/ai_hub.db +.pytest_cache/ \ No newline at end of file diff --git a/ai-hub/app/__init__.py b/ai-hub/app/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/__init__.py +++ b/ai-hub/app/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 04f9d7b..0718c32 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -24,7 +24,7 @@ """ # Initialize core services for RAG # CORRECTED: Now passing the required arguments to FaissVectorStore - vector_store = FaissVectorStore(index_file_path="faiss_index.bin", dimension=768) + vector_store = FaissVectorStore(index_file_path="data/faiss_index.bin", dimension=768) retrievers: List[Retriever] = [ FaissDBRetriever(vector_store=vector_store), ] diff --git a/ai-hub/app/core/__init__.py b/ai-hub/app/core/__init__.py index e69de29..3fbb1fd 100644 --- a/ai-hub/app/core/__init__.py +++ b/ai-hub/app/core/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/llm_providers.py b/ai-hub/app/core/llm_providers.py index f31a701..de36a55 100644 --- a/ai-hub/app/core/llm_providers.py +++ b/ai-hub/app/core/llm_providers.py @@ -45,7 +45,7 @@ async def generate_response(self, prompt: str) -> str: # Construct the request payload messages_payload = [ - # {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": prompt}, ] diff --git a/ai-hub/app/core/pipelines/__init__.py b/ai-hub/app/core/pipelines/__init__.py new file mode 100644 index 0000000..3fbb1fd --- /dev/null +++ b/ai-hub/app/core/pipelines/__init__.py @@ -0,0 +1 @@ +# This file can be left empty. diff --git a/ai-hub/app/core/pipelines/dspy_rag.py b/ai-hub/app/core/pipelines/dspy_rag.py new file mode 100644 index 0000000..322c01b --- /dev/null +++ b/ai-hub/app/core/pipelines/dspy_rag.py @@ -0,0 +1,87 @@ +# In app/core/pipelines/dspy_rag.py + +import dspy +import logging +from typing import List +from types import SimpleNamespace +from sqlalchemy.orm import Session + +from app.core.retrievers import Retriever +from app.core.llm_providers import LLMProvider + +class DSPyLLMProvider(dspy.BaseLM): + """ + A custom wrapper for the LLMProvider to make it compatible with DSPy. + """ + def __init__(self, provider: LLMProvider, model_name: str, **kwargs): + super().__init__(model=model_name) + self.provider = provider + self.kwargs.update(kwargs) + print(f"DSPyLLMProvider initialized for model: {self.model}") + + async def aforward(self, prompt: str, **kwargs): + """ + The required asynchronous forward pass for the language model. + """ + logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") + if not prompt or not prompt.strip(): + logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) + + response_text = await self.provider.generate_response(prompt) + + mock_choice = SimpleNamespace(message=SimpleNamespace(content=response_text, tool_calls=None)) + return SimpleNamespace(choices=[mock_choice], usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), model=self.model) + +class AnswerWithContext(dspy.Signature): + """Given the context, answer the user's question.""" + context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") + question = dspy.InputField() + answer = dspy.OutputField() + +class DspyRagPipeline(dspy.Module): + """ + A simple RAG pipeline that retrieves context and then generates an answer using DSPy. + """ + def __init__(self, retrievers: List[Retriever]): + super().__init__() + self.retrievers = retrievers + # We still define the predictor to access its signature easily. + self.generate_answer = dspy.Predict(AnswerWithContext) + + async def forward(self, question: str, db: Session) -> str: + """ + Executes the RAG pipeline asynchronously. + """ + logging.info(f"[DspyRagPipeline.forward] Received question: '{question}'") + retrieved_contexts = [] + for retriever in self.retrievers: + context = retriever.retrieve_context(question, db) + retrieved_contexts.extend(context) + + context_text = "\n\n".join(retrieved_contexts) + if not context_text: + print("⚠️ No context retrieved. Falling back to direct QA.") + context_text = "No context provided." + + lm = dspy.settings.lm + if lm is None: + raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") + + # --- FIX: Revert to manual prompt construction --- + # Get the instruction from the signature's docstring. + instruction = self.generate_answer.signature.__doc__ + + # Build the full prompt exactly as DSPy would. + full_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {context_text}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + + # Call the language model's aforward method directly with the complete prompt. + response_obj = await lm.aforward(prompt=full_prompt) + + return response_obj.choices[0].message.content \ No newline at end of file diff --git a/ai-hub/app/core/rag_service.py b/ai-hub/app/core/rag_service.py index 7cb57e9..93b4bb9 100644 --- a/ai-hub/app/core/rag_service.py +++ b/ai-hub/app/core/rag_service.py @@ -1,120 +1,28 @@ -import asyncio from typing import List, Dict, Any -from types import SimpleNamespace from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError import dspy -import logging from app.core.vector_store import FaissVectorStore from app.db import models from app.core.retrievers import Retriever -from app.core.llm_providers import LLMProvider, get_llm_provider - -# --- DSPy Components for RAG --- - -class DSPyLLMProvider(dspy.BaseLM): - """ - A custom wrapper for the LLMProvider to make it compatible with DSPy. - """ - def __init__(self, provider: LLMProvider, model_name: str, **kwargs): - super().__init__(model=model_name) - self.provider = provider - self.kwargs.update(kwargs) - print(f"DSPyLLMProvider initialized for model: {self.model}") - - async def aforward(self, prompt: str, **kwargs): - """ - The required asynchronous forward pass for the language model. - """ - logging.info(f"[DSPyLLMProvider.aforward] Received prompt of length: {len(prompt) if prompt else 0}") - - # --- CRITICAL FIX: Ensure prompt is not None or empty --- - if not prompt or not prompt.strip(): - logging.error("[DSPyLLMProvider.aforward] Received a null or empty prompt!") - # Return a default, safe response instead of calling the API with null. - return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="Error: Received an empty prompt."))]) - - # Call the async provider directly using the existing event loop - response_text = await self.provider.generate_response(prompt) - - # Create a mock response object that mimics the OpenAI API structure - mock_choice = SimpleNamespace( - message=SimpleNamespace(content=response_text, tool_calls=None) - ) - mock_response = SimpleNamespace( - choices=[mock_choice], - usage=SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0), - model=self.model - ) - return mock_response - -class AnswerWithContext(dspy.Signature): - """ - Signature for our RAG task: input is a context and question, output is an answer. - """ - context = dspy.InputField(desc="Relevant document snippets from the knowledge base.") - question = dspy.InputField() - answer = dspy.OutputField() - -class RAGPipeline(dspy.Module): - """ - A simple RAG pipeline that retrieves context and then generates an answer. - """ - def __init__(self, retrievers: List[Retriever]): - super().__init__() - self.retrievers = retrievers - # We only need the signature here to generate the prompt text. - self.generate_answer = dspy.Predict(AnswerWithContext) - - async def forward(self, question: str, db: Session) -> str: - """ - Executes the RAG pipeline asynchronously. - """ - logging.info(f"[RAGPipeline.forward] Received question: '{question}'") - retrieved_contexts = [] - for retriever in self.retrievers: - context = retriever.retrieve_context(question, db) - retrieved_contexts.extend(context) - - context_text = "\n\n".join(retrieved_contexts) - if not context_text: - print("⚠️ No context retrieved. Falling back to direct QA.") - context_text = "No context provided." - - # --- REVISED LOGIC --- - # 1. Manually create the full prompt using the signature's template. - # The `dspy.Predict` object can be called with the inputs to get the compiled prompt. - # We access the last generated prompt from the LM's history. - # Since we haven't called the LM yet, we temporarily configure a basic LM. - - # Get the configured language model from dspy settings - lm = dspy.settings.lm - if lm is None: - raise RuntimeError("DSPy LM has not been configured. Call dspy.configure(lm=...) first.") - - # 2. Use the signature to create a dspy.Example, which generates the prompt. - # The dspy.Predict module will format this into a prompt string. - example = dspy.Example(context=context_text, question=question, signatures=self.generate_answer.signature) - - # 3. Call the language model directly with the full prompt string. - # The `example.signatures` contains the logic to render the prompt. - # In modern DSPy, `dspy.predict` is a simpler way to do this. - # We will call the LM's aforward method directly for clarity. - full_prompt = self.generate_answer.signature.instructions.format(context=context_text, question=question) + "\nAnswer:" - - response_obj = await lm.aforward(prompt=full_prompt) - - return response_obj.choices[0].message.content +from app.core.llm_providers import get_llm_provider +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider -# --- Main RAG Service Class --- (This class remains unchanged) 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"], @@ -124,7 +32,9 @@ 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, @@ -144,16 +54,24 @@ 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) - # Configure dspy's global settings with our custom LM + # 3. Configure DSPy's global settings to use our custom LM dspy.configure(lm=dspy_llm_provider) - rag_pipeline = RAGPipeline(retrievers=self.retrievers) + # 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/db/database.py b/ai-hub/app/db/database.py index 2fc97fb..cbb6b13 100644 --- a/ai-hub/app/db/database.py +++ b/ai-hub/app/db/database.py @@ -9,7 +9,7 @@ # Default database URLs POSTGRES_DEFAULT_URL = "postgresql://user:password@localhost/ai_hub_db" -SQLITE_DEFAULT_URL = "sqlite:///./ai_hub.db" +SQLITE_DEFAULT_URL = "sqlite:///./data/ai_hub.db" DATABASE_URL = "" engine_args = {} diff --git a/ai-hub/app/db/guide.md b/ai-hub/app/db/guide.md index a3185c7..e8986e2 100644 --- a/ai-hub/app/db/guide.md +++ b/ai-hub/app/db/guide.md @@ -18,7 +18,7 @@ | Variable | Description | Default Value | Supported Values | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------- | | `DB_MODE` | Specifies the type of database to use | `"postgres"` | `postgres`, `sqlite` | -| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./ai_hub.db` | Any SQLAlchemy URI | +| `DATABASE_URL` | Connection string for the database | PostgreSQL: `postgresql://user:password@localhost/ai_hub_db`
SQLite: `sqlite:///./data/ai_hub.db` | Any SQLAlchemy URI | ### 💡 Example: Switch to SQLite diff --git a/ai-hub/app/db_setup.py b/ai-hub/app/db_setup.py index 739ebd2..63a64a6 100644 --- a/ai-hub/app/db_setup.py +++ b/ai-hub/app/db_setup.py @@ -11,7 +11,7 @@ # This configuration allows for easy switching between SQLite and PostgreSQL. DB_MODE = os.getenv("DB_MODE", "sqlite") if DB_MODE == "sqlite": - DATABASE_URL = "sqlite:///./ai_hub.db" + DATABASE_URL = "sqlite:///./data/ai_hub.db" # The connect_args are needed for SQLite to work with FastAPI's multiple threads engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: diff --git a/ai-hub/tests/core/__init__.py b/ai-hub/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/__init__.py diff --git a/ai-hub/tests/core/pipelines/__init__.py b/ai-hub/tests/core/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ai-hub/tests/core/pipelines/__init__.py diff --git a/ai-hub/tests/core/pipelines/test_dspy_rag.py b/ai-hub/tests/core/pipelines/test_dspy_rag.py new file mode 100644 index 0000000..5e4aa3d --- /dev/null +++ b/ai-hub/tests/core/pipelines/test_dspy_rag.py @@ -0,0 +1,111 @@ +# tests/core/pipelines/test_dspy_rag.py + +import asyncio +from unittest.mock import MagicMock, AsyncMock +from sqlalchemy.orm import Session +import dspy +import pytest + +# Import the pipeline being tested +from app.core.pipelines.dspy_rag import DspyRagPipeline, AnswerWithContext + +# Import its dependencies for mocking +from app.core.retrievers import Retriever + +@pytest.fixture +def mock_lm_configured(): + """ + A pytest fixture to mock the dspy language model and configure it globally + for the duration of a test. + """ + # 1. Create the mock LM object + mock_lm_instance = MagicMock() + # 2. Mock its async `aforward` method to return a dspy-compatible object + mock_lm_instance.aforward = AsyncMock( + return_value=MagicMock( + choices=[MagicMock(message=MagicMock(content="Mocked LLM answer"))] + ) + ) + + # 3. Store the original LM (if any) to restore it after the test + original_lm = dspy.settings.lm + + # 4. CRITICAL FIX: Configure dspy to use our mock LM + dspy.configure(lm=mock_lm_instance) + + # 5. The test runs here, with the mock configured + yield mock_lm_instance + + # 6. After the test, restore the original LM configuration + dspy.configure(lm=original_lm) + + +def test_dspy_rag_pipeline_with_context(mock_lm_configured): + """ + Tests that DspyRagPipeline correctly processes a question when context is found. + """ + # --- Arrange --- + mock_retriever = MagicMock(spec=Retriever) + mock_retriever.retrieve_context.return_value = ["Context chunk 1.", "Context chunk 2."] + mock_db = MagicMock(spec=Session) + + pipeline = DspyRagPipeline(retrievers=[mock_retriever]) + question = "What is the question?" + + # --- Act --- + response = asyncio.run(pipeline.forward(question=question, db=mock_db)) + + # --- Assert --- + # Assert the retriever was called correctly + mock_retriever.retrieve_context.assert_called_once_with(question, mock_db) + + # Assert the language model was called with the correctly constructed prompt + expected_context = "Context chunk 1.\n\nContext chunk 2." + instruction = AnswerWithContext.__doc__ + expected_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {expected_context}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + # The fixture provides the mock_lm_configured object, which is the mock LM + mock_lm_configured.aforward.assert_called_once_with(prompt=expected_prompt) + + # Assert the final answer from the mock is returned + assert response == "Mocked LLM answer" + + +def test_dspy_rag_pipeline_without_context(mock_lm_configured): + """ + Tests that DspyRagPipeline correctly handles the case where no context is found. + """ + # --- Arrange --- + mock_retriever = MagicMock(spec=Retriever) + mock_retriever.retrieve_context.return_value = [] # No context found + mock_db = MagicMock(spec=Session) + + pipeline = DspyRagPipeline(retrievers=[mock_retriever]) + question = "What is the question?" + + # --- Act --- + response = asyncio.run(pipeline.forward(question=question, db=mock_db)) + + # --- Assert --- + # Assert the retriever was called + mock_retriever.retrieve_context.assert_called_once_with(question, mock_db) + + # Assert the LM was called with the placeholder context + expected_context = "No context provided." + instruction = AnswerWithContext.__doc__ + expected_prompt = ( + f"{instruction}\n\n" + f"---\n\n" + f"Context: {expected_context}\n\n" + f"Question: {question}\n\n" + f"Answer:" + ) + mock_lm_configured.aforward.assert_called_once_with(prompt=expected_prompt) + + # Assert the final answer from the mock is returned + assert response == "Mocked LLM answer" \ No newline at end of file diff --git a/ai-hub/tests/core/test_rag_service.py b/ai-hub/tests/core/test_rag_service.py index e0ca1a9..bdd4d17 100644 --- a/ai-hub/tests/core/test_rag_service.py +++ b/ai-hub/tests/core/test_rag_service.py @@ -1,96 +1,60 @@ import asyncio -from unittest.mock import patch, MagicMock, AsyncMock, call +from unittest.mock import patch, MagicMock, AsyncMock from sqlalchemy.orm import Session -import dspy -# Import what you are testing -from app.core.rag_service import RAGService, RAGPipeline, DSPyLLMProvider -# Import dependencies that need to be referenced +# 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.llm_providers import LLMProvider # For type checks if needed +from app.core.pipelines.dspy_rag import DspyRagPipeline, DSPyLLMProvider +from app.core.llm_providers import LLMProvider -# --- RAGService Unit Tests --- -# ... (Your successful add_document tests are fine and don't need changes) ... - -# NOTE: The patch target for get_llm_provider has been corrected. @patch('app.core.rag_service.get_llm_provider') -@patch('app.core.rag_service.RAGPipeline') +@patch('app.core.rag_service.DspyRagPipeline') # Patched the new class name @patch('dspy.configure') -def test_rag_service_chat_with_rag_with_context(mock_configure, mock_rag_pipeline, mock_get_llm_provider): +def test_rag_service_orchestration(mock_configure, mock_dspy_pipeline, mock_get_llm_provider): """ - Test the RAGService.chat_with_rag method when context is retrieved. + 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_retriever.retrieve_context.return_value = ["Context text 1.", "Context text 2."] - mock_rag_pipeline_instance = MagicMock(spec=RAGPipeline) - mock_rag_pipeline_instance.forward = AsyncMock(return_value="LLM response with context") - mock_rag_pipeline.return_value = mock_rag_pipeline_instance + # 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="deepseek")) + response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model=model)) # --- Assert --- - mock_get_llm_provider.assert_called_once_with("deepseek") - + # 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) - # FIX 1: Assert it's an instance of the correct wrapper class. - assert isinstance(lm_instance, DSPyLLMProvider) - # This assertion will now pass because the patch target is correct. - assert lm_instance.provider == mock_llm_provider - - mock_rag_pipeline.assert_called_once_with(retrievers=[mock_retriever]) - mock_rag_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) - assert response_text == "LLM response with context" - - -# NOTE: The patch target for get_llm_provider has been corrected. -@patch('app.core.rag_service.get_llm_provider') -@patch('app.core.rag_service.RAGPipeline') -@patch('dspy.configure') -def test_rag_service_chat_with_rag_without_context(mock_configure, mock_rag_pipeline, mock_get_llm_provider): - """ - Test the RAGService.chat_with_rag method when no context is retrieved. - """ - # --- Arrange --- - mock_db = MagicMock(spec=Session) - mock_llm_provider = MagicMock(spec=LLMProvider) - mock_get_llm_provider.return_value = mock_llm_provider - - mock_retriever = MagicMock(spec=Retriever) - mock_retriever.retrieve_context.return_value = [] - - mock_rag_pipeline_instance = MagicMock(spec=RAGPipeline) - mock_rag_pipeline_instance.forward = AsyncMock(return_value="LLM response without context") - mock_rag_pipeline.return_value = mock_rag_pipeline_instance - - rag_service = RAGService(vector_store=MagicMock(), retrievers=[mock_retriever]) - prompt = "Test prompt without context." - - # --- Act --- - response_text = asyncio.run(rag_service.chat_with_rag(db=mock_db, prompt=prompt, model="deepseek")) - - # --- Assert --- - mock_get_llm_provider.assert_called_once_with("deepseek") - - mock_configure.assert_called_once() - lm_instance = mock_configure.call_args.kwargs['lm'] - - assert isinstance(lm_instance, DSPyLLMProvider) - # This assertion will now pass because the patch target is correct. - assert lm_instance.provider == mock_llm_provider - - mock_rag_pipeline.assert_called_once_with(retrievers=[mock_retriever]) - mock_rag_pipeline_instance.forward.assert_called_once_with(question=prompt, db=mock_db) - assert response_text == "LLM response without context" \ No newline at end of file + # 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/db/test_database.py b/ai-hub/tests/db/test_database.py index 4f8921a..8287067 100644 --- a/ai-hub/tests/db/test_database.py +++ b/ai-hub/tests/db/test_database.py @@ -19,7 +19,7 @@ # Assert: Check if the configuration is correct for SQLite assert database.DB_MODE == "sqlite" - assert "sqlite:///./ai_hub.db" in database.DATABASE_URL + assert "sqlite:///./data/ai_hub.db" in database.DATABASE_URL assert "connect_args" in database.engine_args assert database.engine_args["connect_args"] == {"check_same_thread": False}