diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 00000000..e69de29b diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 027eb5f2..9e73b605 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,12 +26,14 @@ jobs: credentials_json: ${{ secrets.PRODUCTION_CREDENTIALS_JSON }} # Dynamically Update app.yaml - - name: Update app.yaml + - name: Update app.yaml with env variables run: | - echo "" >> app/app.yaml - echo "env_variables:" >> app/app.yaml - echo " ENV_TYPE: '${{ env.ENV_TYPE }}'" >> app/app.yaml - echo " PROJECT_ID: '${{ env.PROJECT_ID }}'" >> app/app.yaml + echo "" >> app.yaml + echo "env_variables:" >> app.yaml + echo " ENV_TYPE: '${{ env.ENV_TYPE }}'" >> app.yaml + echo " PROJECT_ID: '${{ env.PROJECT_ID }}'" >> app.yaml + echo " GOOGLE_API_KEY: '${{ secrets.GOOGLE_API_KEY }}'" >> app.yaml + echo " PYTHONPATH: app/" >> app.yaml - name: "Set up Cloud SDK" uses: "google-github-actions/setup-gcloud@v1" @@ -42,5 +44,5 @@ jobs: if: github.ref == 'refs/heads/main' uses: "google-github-actions/deploy-appengine@v0.2.0" with: - deliverables: app/app.yaml + deliverables: app.yaml version: v1 diff --git a/Dockerfile b/Dockerfile index b95b1d32..b16a3d39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,19 @@ # backend/Dockerfile FROM python:3.10.12 -WORKDIR /app +WORKDIR /code -COPY app/ /app +COPY app/requirements.txt /code/requirements.txt -RUN pip install --no-cache-dir -r /app/requirements.txt +RUN pip install --no-cache-dir -r /code/requirements.txt -COPY ./app /app +COPY ./app /code/app # Local development key set # ENV TYPES: dev, production # When set to dev, API Key on endpoint requests are just 'dev' # When set to production, API Key on endpoint requests are the actual API Key -ENV GOOGLE_APPLICATION_CREDENTIALS=/app/local-auth.json -ENV ENV_TYPE="dev" -ENV PROJECT_ID="kai-ai-f63c8" +ENV PYTHONPATH=/code/app -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["fastapi", "run", "app/main.py", "--port", "8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3c018b89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Radical AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ff89f440..c30cfb3d 100644 --- a/README.md +++ b/README.md @@ -89,25 +89,11 @@ pip install -r requirements.txt 1. Rename the downloaded JSON key to `local-auth.json`. 2. Move or copy this file to your application's directory, specifically inside the `/app` directory. -### Step 4: Set Environment Variables - -1. Open your command line interface. -2. Set the path to the JSON key file by running: - ```bash - set GOOGLE_APPLICATION_CREDENTIALS=/app/local-auth.json``` -## Set the environment type and project ID: - - -```bash - set ENV_TYPE="dev" - set PROJECT_ID="Enter your project ID here" -``` - -```bash - uvicorn main:app --reload -``` - +### Step 4: Utilize Local Start Script +1. Modify the `local-start.sh` script's environment variable `PROJECT_ID` to match the project ID of your Google Cloud project. +2. Run the script: `./local-start.sh` +3. Navigate to `http://localhost:8000` to view the application. # Docker Setup Guide @@ -153,7 +139,10 @@ The Docker container uses several key environment variables: `LANGCHAIN_ENDPOINT` `LANGCHAIN_API_KEY` `LANGCHAIN_PROJECT` -- Ensure these variables are correctly configured in your Dockerfile or passed as additional parameters to your Docker run command if needed. +- Ensure these variables are correctly configured in your Dockerfile or passed as additional parameters to your Docker run command, as shown in the example below: + ```bash + docker run --env ENV_TYPE=dev --env="Enter your project ID here" -p 8000:8000 kai-backend:latest + ``` ## Accessing the Application You can access the backend by visiting: ```Bash diff --git a/app.yaml b/app.yaml new file mode 100644 index 00000000..4a9d1ce3 --- /dev/null +++ b/app.yaml @@ -0,0 +1,6 @@ +runtime: python310 +entrypoint: fastapi run app/main.py --port $PORT +instance_class: F2 +automatic_scaling: + min_instances: 1 + max_instances: 3 \ No newline at end of file diff --git a/app/.env.sample b/app/.env.sample new file mode 100644 index 00000000..d9bcd22e --- /dev/null +++ b/app/.env.sample @@ -0,0 +1,2 @@ +ENV_TYPE= +GOOGLE_API_KEY= \ No newline at end of file diff --git a/app/.gcloudignore b/app/.gcloudignore deleted file mode 100644 index a940569a..00000000 --- a/app/.gcloudignore +++ /dev/null @@ -1,21 +0,0 @@ -# This file specifies files that are *not* uploaded to Google Cloud -# using gcloud. It follows the same syntax as .gitignore, with the addition of -# "#!include" directives (which insert the entries of the given .gitignore-style -# file at that point). -# -# For more information, run: -# $ gcloud topic gcloudignore -# -.gcloudignore -# If you would like to upload your .git directory, .gitignore file or files -# from your .gitignore file, remove the corresponding line -# below: -.git -.gitignore - -# Python pycache: -__pycache__/ -# Ignored by the build system -/setup.cfg -env/ -local-auth.json \ No newline at end of file diff --git a/app/api/router.py b/app/api/router.py index ced77317..de7962ee 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,11 +2,11 @@ from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from typing import Union -from services.schemas import ToolRequest, ChatRequest, Message, ChatResponse, ToolResponse -from utils.auth import key_check -from services.logger import setup_logger -from api.error_utilities import InputValidationError, ErrorResponse -from api.tool_utilities import load_tool_metadata, execute_tool, finalize_inputs +from app.services.schemas import ToolRequest, ChatRequest, Message, ChatResponse, ToolResponse +from app.utils.auth import key_check +from app.services.logger import setup_logger +from app.api.error_utilities import InputValidationError, ErrorResponse +from app.api.tool_utilities import load_tool_metadata, execute_tool, finalize_inputs logger = setup_logger(__name__) router = APIRouter() @@ -46,7 +46,7 @@ async def submit_tool( data: ToolRequest, _ = Depends(key_check)): @router.post("/chat", response_model=ChatResponse) async def chat( request: ChatRequest, _ = Depends(key_check) ): - from features.Kaichat.core import executor as kaichat_executor + from app.features.Kaichat.core import executor as kaichat_executor user_name = request.user.fullName chat_messages = request.messages diff --git a/app/api/tests/test_tool_utility.py b/app/api/tests/test_tool_utility.py index ef3ec0b5..2471a1c7 100644 --- a/app/api/tests/test_tool_utility.py +++ b/app/api/tests/test_tool_utility.py @@ -1,9 +1,9 @@ import json import pytest from unittest.mock import patch, MagicMock, mock_open -from services.tool_registry import BaseTool, ToolInput, ToolFile +from app.services.tool_registry import BaseTool, ToolInput, ToolFile from fastapi import HTTPException -from api.tool_utilities import get_executor_by_name, load_tool_metadata, prepare_input_data, execute_tool +from app.api.tool_utilities import get_executor_by_name, load_tool_metadata, prepare_input_data, execute_tool # Sample configuration for tools_config tools_config = { diff --git a/app/api/tests/test_tools.py b/app/api/tests/test_tools.py index 8eb58f10..fc5e5102 100644 --- a/app/api/tests/test_tools.py +++ b/app/api/tests/test_tools.py @@ -1,6 +1,6 @@ from fastapi.testclient import TestClient from main import app -from services.tool_registry import validate_inputs +from app.services.tool_registry import validate_inputs import pytest import os diff --git a/app/api/tool_utilities.py b/app/api/tool_utilities.py index 182e2fed..092e2d55 100644 --- a/app/api/tool_utilities.py +++ b/app/api/tool_utilities.py @@ -1,8 +1,8 @@ import json import os -from services.logger import setup_logger -from services.tool_registry import ToolFile -from api.error_utilities import VideoTranscriptError, InputValidationError, ToolExecutorError +from app.services.logger import setup_logger +from app.services.tool_registry import ToolFile +from app.api.error_utilities import VideoTranscriptError, InputValidationError, ToolExecutorError from typing import Dict, Any, List from fastapi import HTTPException from pydantic import ValidationError @@ -32,9 +32,16 @@ def load_tool_metadata(tool_id): logger.error(f"No tool configuration found for tool_id: {tool_id}") raise HTTPException(status_code=404, detail="Tool configuration not found") - # The path to the module needs to be split and only the directory path should be used. - module_dir_path = '/'.join(tool_config['path'].split('.')[:-1]) # This removes the last segment (core) - file_path = os.path.join(os.getcwd(), module_dir_path, tool_config['metadata_file']) + # Ensure the base path is relative to the current file's directory + base_dir = os.path.dirname(os.path.abspath(__file__)) + logger.debug(f"Base directory: {base_dir}") + + # Construct the directory path + module_dir_path = os.path.join(base_dir, '..', *tool_config['path'].split('.')[:-1]) # Go one level up and then to the path + module_dir_path = os.path.abspath(module_dir_path) # Get absolute path + logger.debug(f"Module directory path: {module_dir_path}") + + file_path = os.path.join(module_dir_path, tool_config['metadata_file']) logger.debug(f"Checking metadata file at: {file_path}") if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: @@ -51,47 +58,57 @@ def prepare_input_data(input_data) -> Dict[str, Any]: inputs = {input.name: input.value for input in input_data} return inputs -def validate_inputs(request_data: Dict[str, Any], validate_data: List[Dict[str, str]]) -> bool: - validate_inputs = {input_item['name']: input_item['type'] for input_item in validate_data} - - # Check for missing inputs - for validate_input_name, input_type in validate_inputs.items(): +def check_missing_inputs(request_data: Dict[str, Any], validate_inputs: Dict[str, str]): + for validate_input_name in validate_inputs: if validate_input_name not in request_data: error_message = f"Missing input: `{validate_input_name}`" logger.error(error_message) raise InputValidationError(error_message) - # Validate each input in request data against validate definitions - for input_name, input_value in request_data.items(): - if input_name not in validate_inputs: - continue # Skip validation for extra inputs not defined in validate +def raise_type_error(input_name: str, input_value: Any, expected_type: str): + error_message = f"Input `{input_name}` must be a {expected_type} but got {type(input_value)}" + logger.error(error_message) + raise InputValidationError(error_message) - expected_type = validate_inputs[input_name] - if expected_type == 'text' and not isinstance(input_value, str): - error_message = f"Input `{input_name}` must be a string but got {type(input_value)}" +def validate_file_input(input_name: str, input_value: Any): + if not isinstance(input_value, list): + error_message = f"Input `{input_name}` must be a list of file dictionaries but got {type(input_value)}" + logger.error(error_message) + raise InputValidationError(error_message) + + for file_obj in input_value: + if not isinstance(file_obj, dict): + error_message = f"Each item in the input `{input_name}` must be a dictionary representing a file but got {type(file_obj)}" logger.error(error_message) raise InputValidationError(error_message) - elif expected_type == 'number' and not isinstance(input_value, (int, float)): - error_message = f"Input `{input_name}` must be a number but got {type(input_value)}" + try: + ToolFile.model_validate(file_obj, from_attributes=True) # This will raise a validation error if the structure is incorrect + except ValidationError: + error_message = f"Each item in the input `{input_name}` must be a valid ToolFile where a URL is provided" logger.error(error_message) raise InputValidationError(error_message) - elif expected_type == 'file': - # Validate file inputs - if not isinstance(input_value, list): - error_message = f"Input `{input_name}` must be a list of file dictionaries but got {type(input_value)}" - logger.error(error_message) - raise InputValidationError(error_message) - for file_obj in input_value: - if not isinstance(file_obj, dict): - error_message = f"Each item in the input `{input_name}` must be a dictionary representing a file but got {type(file_obj)}" - logger.error(error_message) - raise InputValidationError(error_message) - try: - ToolFile.model_validate(file_obj, from_attributes=True) # This will raise a validation error if the structure is incorrect - except ValidationError: - error_message = f"Each item in the input `{input_name}` must be a valid ToolFile where a url is provided" - logger.error(error_message) - raise InputValidationError(error_message) + +def validate_input_type(input_name: str, input_value: Any, expected_type: str): + if expected_type == 'text' and not isinstance(input_value, str): + raise_type_error(input_name, input_value, "string") + elif expected_type == 'number' and not isinstance(input_value, (int, float)): + raise_type_error(input_name, input_value, "number") + elif expected_type == 'file': + validate_file_input(input_name, input_value) + +def validate_inputs(request_data: Dict[str, Any], validate_data: List[Dict[str, str]]) -> bool: + validate_inputs = {input_item['name']: input_item['type'] for input_item in validate_data} + + # Check for missing inputs + check_missing_inputs(request_data, validate_inputs) + + # Validate each input in request data against validate definitions + for input_name, input_value in request_data.items(): + if input_name not in validate_inputs: + continue # Skip validation for extra inputs not defined in validate_inputs + + expected_type = validate_inputs[input_name] + validate_input_type(input_name, input_value, expected_type) return True diff --git a/app/app.yaml b/app/app.yaml deleted file mode 100644 index 25ac0e37..00000000 --- a/app/app.yaml +++ /dev/null @@ -1,6 +0,0 @@ -runtime: python310 -entrypoint: uvicorn main:app --host 0.0.0.0 --port $PORT -instance_class: F1 -automatic_scaling: - min_instances: 1 - max_instances: 3 \ No newline at end of file diff --git a/app/features/Kaichat/core.py b/app/features/Kaichat/core.py index e9bddd13..53df15cd 100644 --- a/app/features/Kaichat/core.py +++ b/app/features/Kaichat/core.py @@ -1,6 +1,6 @@ -from langchain_google_vertexai import VertexAI +from langchain_google_genai import GoogleGenerativeAI from langchain.prompts import PromptTemplate -from services.schemas import ChatMessage, Message +from app.services.schemas import ChatMessage, Message import os def read_text_file(file_path): @@ -40,7 +40,7 @@ def executor(user_name: str, user_query: str, messages: list[Message], k=10): prompt = build_prompt() - llm = VertexAI(model_name="gemini-1.0-pro") + llm = GoogleGenerativeAI(model="gemini-1.0-pro") chain = prompt | llm diff --git a/app/features/dynamo/core.py b/app/features/dynamo/core.py index a75b6668..73670c24 100644 --- a/app/features/dynamo/core.py +++ b/app/features/dynamo/core.py @@ -1,6 +1,6 @@ -from features.dynamo.tools import summarize_transcript, generate_flashcards -from services.logger import setup_logger -from api.error_utilities import VideoTranscriptError +from app.features.dynamo.tools import summarize_transcript, generate_flashcards +from app.services.logger import setup_logger +from app.api.error_utilities import VideoTranscriptError logger = setup_logger(__name__) diff --git a/app/features/dynamo/metadata.json b/app/features/dynamo/metadata.json index e9380daf..55c2858d 100644 --- a/app/features/dynamo/metadata.json +++ b/app/features/dynamo/metadata.json @@ -5,5 +5,10 @@ "name": "youtube_url", "type": "string" } - ] + ], + "models": { + "Gemini 1.0": "gemini-1.0-pro", + "Gemini 1.5 Flash": "gemini-1.5-flash", + "Gemini 1.5 Pro": "gemini-1.5-pro" + } } \ No newline at end of file diff --git a/app/features/dynamo/prompt/summarize-prompt.txt b/app/features/dynamo/prompt/summarize-prompt.txt new file mode 100644 index 00000000..76eb81a9 --- /dev/null +++ b/app/features/dynamo/prompt/summarize-prompt.txt @@ -0,0 +1,3 @@ +You are a video summarizing AI who only summarizes transcript in a concise, readable, and informative format. You can analyze large amounts of text to isolate the key concepts and ideas from the transcript while ignoring tangents. Consider the following video transcript and respond with paragraphs which highlight the core ideas represented. Do not include any headers, markdown, or other page content other than plaintext of the summary. + +{full_transcript} \ No newline at end of file diff --git a/app/features/dynamo/tools.py b/app/features/dynamo/tools.py index f2ba4c3e..e00a7d90 100644 --- a/app/features/dynamo/tools.py +++ b/app/features/dynamo/tools.py @@ -1,20 +1,20 @@ from langchain_community.document_loaders import YoutubeLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain.prompts import PromptTemplate -from langchain_google_vertexai import VertexAI +from langchain_google_genai import GoogleGenerativeAI from langchain_core.output_parsers import JsonOutputParser from langchain.chains.summarize import load_summarize_chain from langchain_core.pydantic_v1 import BaseModel, Field -from api.error_utilities import VideoTranscriptError +from app.api.error_utilities import VideoTranscriptError from fastapi import HTTPException -from services.logger import setup_logger +from app.services.logger import setup_logger import os logger = setup_logger(__name__) # AI Model -model = VertexAI(model="gemini-1.0-pro") +model = GoogleGenerativeAI(model="gemini-1.0-pro") def read_text_file(file_path): @@ -49,26 +49,35 @@ def summarize_transcript(youtube_url: str, max_video_length=600, verbose=False) ) split_docs = splitter.split_documents(docs) + + full_transcript = [doc.page_content for doc in split_docs] + full_transcript = " ".join(full_transcript) + + full_transcript = [doc.page_content for doc in split_docs] + full_transcript = " ".join(full_transcript) if length > max_video_length: raise VideoTranscriptError(f"Video is {length} seconds long, please provide a video less than {max_video_length} seconds long", youtube_url) if verbose: logger.info(f"Found video with title: {title} and length: {length}") - logger.info(f"Splitting documents into {len(split_docs)} chunks") + logger.info(f"Combined documents into a single string.") + logger.info(f"Beginning to process transcript...") - chain = load_summarize_chain(model, chain_type='map_reduce') - response = chain.invoke(split_docs) + prompt_template = read_text_file("prompt/summarize-prompt.txt") + summarize_prompt = PromptTemplate.from_template(prompt_template) + + summarize_model = GoogleGenerativeAI(model="gemini-1.5-flash") - if response and verbose: logger.info("Successfully completed generating summary") + chain = summarize_prompt | summarize_model - return response['output_text'] + return chain.invoke(full_transcript) def generate_flashcards(summary: str, verbose=False) -> list: # Receive the summary from the map reduce chain and generate flashcards parser = JsonOutputParser(pydantic_object=Flashcard) - if verbose: logger.info(f"Beginning to process summary") + if verbose: logger.info(f"Beginning to process flashcards from summary") template = read_text_file("prompt/dynamo-prompt.txt") examples = read_text_file("prompt/examples.txt") @@ -90,6 +99,6 @@ def generate_flashcards(summary: str, verbose=False) -> list: return response class Flashcard(BaseModel): - concept: str = Field(description="The concept of the flashcard") + concept: str = Field(description="The concept of the flashcard") definition: str = Field(description="The definition of the flashcard") diff --git a/app/features/quizzify/core.py b/app/features/quizzify/core.py index 73865c5f..5d19b903 100644 --- a/app/features/quizzify/core.py +++ b/app/features/quizzify/core.py @@ -1,8 +1,8 @@ -from services.tool_registry import ToolFile -from services.logger import setup_logger -from features.quizzify.tools import RAGpipeline -from features.quizzify.tools import QuizBuilder -from api.error_utilities import LoaderError, ToolExecutorError +from app.services.tool_registry import ToolFile +from app.services.logger import setup_logger +from app.features.quizzify.tools import RAGpipeline +from app.features.quizzify.tools import QuizBuilder +from app.api.error_utilities import LoaderError, ToolExecutorError logger = setup_logger() diff --git a/app/features/quizzify/tests/test_loaders.py b/app/features/quizzify/tests/test_loaders.py index 8c4f70d5..2acaf63b 100644 --- a/app/features/quizzify/tests/test_loaders.py +++ b/app/features/quizzify/tests/test_loaders.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import patch, MagicMock -from services.tool_registry import ToolFile -from features.quizzify.tools import URLLoader, BytesFilePDFLoader, Document # Adjust the import path as necessary +from app.services.tool_registry import ToolFile +from app.features.quizzify.tools import URLLoader, BytesFilePDFLoader, Document # Adjust the import path as necessary @pytest.fixture def pdf_loader(): diff --git a/app/features/quizzify/tools.py b/app/features/quizzify/tools.py index ee0392c1..d9f159bb 100644 --- a/app/features/quizzify/tools.py +++ b/app/features/quizzify/tools.py @@ -11,20 +11,38 @@ from langchain_core.documents import Document from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_chroma import Chroma -from langchain_google_vertexai import VertexAIEmbeddings, VertexAI from langchain_core.prompts import PromptTemplate from langchain_core.runnables import RunnablePassthrough, RunnableParallel from langchain_core.output_parsers import JsonOutputParser from langchain_core.pydantic_v1 import BaseModel, Field +from langchain_google_genai import GoogleGenerativeAI +from langchain_google_genai import GoogleGenerativeAIEmbeddings -from services.logger import setup_logger -from services.tool_registry import ToolFile -from api.error_utilities import LoaderError +from app.services.logger import setup_logger +from app.services.tool_registry import ToolFile +from app.api.error_utilities import LoaderError relative_path = "features/quzzify" logger = setup_logger(__name__) +def transform_json_dict(input_data: dict) -> dict: + # Validate and parse the input data to ensure it matches the QuizQuestion schema + quiz_question = QuizQuestion(**input_data) + + # Transform the choices list into a dictionary + transformed_choices = {choice.key: choice.value for choice in quiz_question.choices} + + # Create the transformed structure + transformed_data = { + "question": quiz_question.question, + "choices": transformed_choices, + "answer": quiz_question.answer, + "explanation": quiz_question.explanation + } + + return transformed_data + def read_text_file(file_path): # Get the directory containing the script file script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -182,7 +200,7 @@ def __init__(self, loader=None, splitter=None, vectorstore_class=None, embedding "loader": URLLoader(verbose = verbose), # Creates instance on call with verbosity "splitter": RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100), "vectorstore_class": Chroma, - "embedding_model": VertexAIEmbeddings(model='textembedding-gecko') + "embedding_model": GoogleGenerativeAIEmbeddings(model='models/embedding-001') } self.loader = loader or default_config["loader"] self.splitter = splitter or default_config["splitter"] @@ -247,7 +265,7 @@ def __call__(self, documents): class QuizBuilder: def __init__(self, vectorstore, topic, prompt=None, model=None, parser=None, verbose=False): default_config = { - "model": VertexAI(model="gemini-1.0-pro"), + "model": GoogleGenerativeAI(model="gemini-1.0-pro"), "parser": JsonOutputParser(pydantic_object=QuizQuestion), "prompt": read_text_file("prompt/quizzify-prompt.txt") } @@ -319,7 +337,8 @@ def create_questions(self, num_questions: int = 5) -> List[Dict]: response = chain.invoke(self.topic) if self.verbose: logger.info(f"Generated response attempt {attempts + 1}: {response}") - + + response = transform_json_dict(response) # Directly check if the response format is valid if self.validate_response(response): response["choices"] = self.format_choices(response["choices"]) @@ -345,10 +364,30 @@ def create_questions(self, num_questions: int = 5) -> List[Dict]: return generated_questions[:num_questions] class QuestionChoice(BaseModel): - key: str = Field(description="A unique identifier for the choice using letters A, B, C, D, etc.") + key: str = Field(description="A unique identifier for the choice using letters A, B, C, or D.") value: str = Field(description="The text content of the choice") class QuizQuestion(BaseModel): question: str = Field(description="The question text") - choices: List[QuestionChoice] = Field(description="A list of choices") - answer: str = Field(description="The correct answer") + choices: List[QuestionChoice] = Field(description="A list of choices for the question, each with a key and a value") + answer: str = Field(description="The key of the correct answer from the choices list") explanation: str = Field(description="An explanation of why the answer is correct") + + model_config = { + "json_schema_extra": { + "examples": """ + { + "question": "What is the capital of France?", + "choices": [ + {"key": "A", "value": "Berlin"}, + {"key": "B", "value": "Madrid"}, + {"key": "C", "value": "Paris"}, + {"key": "D", "value": "Rome"} + ], + "answer": "C", + "explanation": "Paris is the capital of France." + } + """ + } + + } + diff --git a/app/main.py b/app/main.py index 6b44c5dc..4a5c44e7 100644 --- a/app/main.py +++ b/app/main.py @@ -3,9 +3,14 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager -from api.router import router -from services.logger import setup_logger -from api.error_utilities import ErrorResponse +from app.api.router import router +from app.services.logger import setup_logger +from app.api.error_utilities import ErrorResponse + +import os +from dotenv import load_dotenv, find_dotenv + +load_dotenv(find_dotenv()) logger = setup_logger(__name__) diff --git a/app/requirements.txt b/app/requirements.txt index d4c5c6f2..767cf764 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] langchain langchain-core -langchain-google-vertexai +langchain-google-genai langchain-chroma langchain-community google-cloud-secret-manager @@ -14,4 +14,5 @@ chroma pypdf fpdf youtube-transcript-api -pytube \ No newline at end of file +pytube +python-dotenv \ No newline at end of file diff --git a/app/services/schemas.py b/app/services/schemas.py index 056b8165..cb05c028 100644 --- a/app/services/schemas.py +++ b/app/services/schemas.py @@ -1,7 +1,7 @@ from pydantic import BaseModel from typing import Optional, List, Any from enum import Enum -from services.tool_registry import BaseTool +from app.services.tool_registry import BaseTool class User(BaseModel): diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index e1ab872c..18a31b26 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from services.logger import setup_logger +from app.services.logger import setup_logger from typing import List, Any, Optional, Dict -from api.error_utilities import InputValidationError +from app.api.error_utilities import InputValidationError logger = setup_logger(__name__) diff --git a/load_env.sh b/load_env.sh new file mode 100644 index 00000000..8b070880 --- /dev/null +++ b/load_env.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Read each line in the .env file +while IFS== read -r key value; do + # Skip blank lines and comments starting with # + if [[ -n "$key" && ! "$key" =~ ^[[:space:]]*# ]]; then + # Export the variable (makes it available to the script) + echo "export $key=$value" + export "$key=$value" + fi +done < ".env" \ No newline at end of file diff --git a/local-start.sh b/local-start.sh new file mode 100755 index 00000000..4bc0b9a6 --- /dev/null +++ b/local-start.sh @@ -0,0 +1,25 @@ +source ./load_env.sh + +echo "Starting local server\n" + +export LANGCHAIN_TRACING_V2=true +export LANGCHAIN_ENDPOINT="https://api.smith.langchain.com" +export LANGCHAIN_API_KEY=$LANGSMITH_API_KEY +export LANGCHAIN_PROJECT=$LANGSMITH_PROJECT +export GOOGLE_API_KEY=$GOOGLE_API_KEY + +#export GOOGLE_APPLICATION_CREDENTIALS=$(pwd)/app/local-auth.json +export ENV_TYPE=dev +export PROJECT_ID=$GCP_PROJECT_ID +export PYTHONPATH=$(pwd)/app + +echo "Loaded environment variables:" +echo "LANGCHAIN_TRACING_V2: $LANGCHAIN_TRACING_V2" +echo "LANGCHAIN_ENDPOINT: $LANGCHAIN_ENDPOINT" +echo "LANGCHAIN_API_KEY: $LANGCHAIN_API_KEY" +echo "LANGCHAIN_PROJECT: $LANGCHAIN_PROJECT" +echo "ENV_TYPE: $ENV_TYPE" +echo "PROJECT_ID: $PROJECT_ID" +echo "GOOGLE_API_KEY: $GOOGLE_API_KEY" + +fastapi dev app/main.py \ No newline at end of file