diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..a6c3251 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +.DS_Store + +docs +.git +config.json +Pipfile +Pipfile.lock + +# Cache +.mypy_cache +.ruff_cache +.pytest_cache +*__pycache__* +*.egg-info +*.pyc + +# Machine specific +.idea +.vscode + +# Ignore .env files +.env +.envrc + +# ignore virtualenvs +.venv +venv* +aienv* +apienv* +appenv* +llmenv* + +.ipynb_checkpoints diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c3e3bd7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_size = 2 +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +indent_size = 4 diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml new file mode 100644 index 0000000..6e0eee7 --- /dev/null +++ b/.github/workflows/docker-images.yml @@ -0,0 +1,33 @@ +name: Build Docker Images + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build-api-image: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Docker Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ secrets.DOCKERHUB_REPO }}/ai-api:dev, ${{ secrets.DOCKERHUB_REPO }}/ai-api:prd diff --git a/.github/workflows/ecr-images.yml b/.github/workflows/ecr-images.yml new file mode 100644 index 0000000..f1eac61 --- /dev/null +++ b/.github/workflows/ecr-images.yml @@ -0,0 +1,44 @@ +name: Build ECR Images + +on: workflow_dispatch + +permissions: + # For AWS OIDC Token access as per https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services#updating-your-github-actions-workflow + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +env: + ECR_REPO: YOUR ECR REPO + # Create role using https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/ + AWS_ROLE: YOUR_ROLE_ARN + AWS_REGION: us-east-1 + +jobs: + build-api-image: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # https://github.com/marketplace/actions/configure-aws-credentials-action-for-github-actions + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE }} + aws-region: ${{ env.AWS_REGION }} + # https://github.com/marketplace/actions/amazon-ecr-login-action-for-github-actions + - name: ECR Login + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + - name: Build, tag, and push docker image to Amazon ECR + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.ECR_REPO }}/ai-api:dev, ${{ env.ECR_REPO }}/ai-api:prd diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..a00194a --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,43 @@ +name: Validate + +on: + pull_request: + types: + - opened + - edited + - reopened + branches: + - "main" + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + - name: Install dependencies + run: | + python -m pip install -U \ + pip setuptools wheel \ + mypy pytest ruff + pip install --no-deps -r requirements.txt + - name: Format with ruff + run: | + ruff format . + - name: Lint with ruff + run: | + ruff check . + - name: Type-check with mypy + run: | + mypy . + - name: Test with pytest + run: | + pytest . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e62852 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +.DS_Store + +# Python cache +.mypy_cache +*__pycache__* +*.egg-info +*.pyc +*.pytest_cache +*.ruff_cache +*.cache* +*.config* + +# Machine specific +.idea +.vscode + +# Ignore .env files +.env +.envrc + +# ignore storage dir +storage + +# ignore .local dir +.local + +# ignore dist dir +dist + +# ignore virtualenvs +.venv +venv* +aienv* +apienv* +appenv* +llmenv* + +# ignore jupyter checkpoints +.ipynb_checkpoints +.Trash* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..45be732 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM phidata/python:3.11.5 + +ARG USER=app +ARG APP_DIR=${USER_LOCAL_DIR}/${USER} +ENV APP_DIR=${APP_DIR} +# Add APP_DIR to PYTHONPATH +ENV PYTHONPATH="${APP_DIR}:${PYTHONPATH}" + +# Create user and home directory +RUN groupadd -g 61000 ${USER} \ + && useradd -g 61000 -u 61000 -ms /bin/bash -d ${APP_DIR} ${USER} + +WORKDIR ${APP_DIR} + +# Update pip +RUN pip install --upgrade pip +# Copy pinned requirements +COPY requirements.txt . +# Install pinned requirements +RUN pip install -r requirements.txt + +# Copy project files +COPY . . + +COPY scripts /scripts +ENTRYPOINT ["/scripts/entrypoint.sh"] +CMD ["chill"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5512eef --- /dev/null +++ b/LICENSE @@ -0,0 +1,375 @@ +Copyright (c) 2022 Phidata, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a065875 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +## AI API + +This repo contains the code for running an AI Api in 2 environments: + +1. `dev`: A development environment running locally on docker +2. `prd`: A production environment running on AWS ECS + +## Setup Workspace + +1. Clone the git repo + +> from the `ai-api` dir: + +2. Create + activate a virtual env: + +```sh +python3 -m venv aienv +source aienv/bin/activate +``` + +3. Install `phidata`: + +```sh +pip install phidata +``` + +4. Setup workspace: + +```sh +phi ws setup +``` + +5. Copy `workspace/example_secrets` to `workspace/secrets`: + +```sh +cp -r workspace/example_secrets workspace/secrets +``` + +6. Optional: Create `.env` file: + +```sh +cp example.env .env +``` + +## Run Api locally + +1. Install [docker desktop](https://www.docker.com/products/docker-desktop) + +2. Set OpenAI Key + +Set the `OPENAI_API_KEY` environment variable using + +```sh +export OPENAI_API_KEY=sk-*** +``` + +**OR** set in the `.env` file + +3. Start the workspace using: + +```sh +phi ws up +``` + +Open [localhost:8000/docs](http://localhost:8000/docs) to view the FastApi docs. + +4. Stop the workspace using: + +```sh +phi ws down +``` + +## Next Steps: + +- [Run the Api App on AWS](https://docs.phidata.com/templates/ai-api/run-aws) +- Read how to [manage the development application](https://docs.phidata.com/how-to/development-app) +- Read how to [manage the production application](https://docs.phidata.com/how-to/production-app) +- Read how to [add python libraries](https://docs.phidata.com/how-to/python-libraries) +- Read how to [format & validate your code](https://docs.phidata.com/how-to/format-and-validate) +- Read how to [manage secrets](https://docs.phidata.com/how-to/secrets) +- Add [CI/CD](https://docs.phidata.com/how-to/ci-cd) +- Add [database tables](https://docs.phidata.com/how-to/database-tables) +- Read the [Api App guide](https://docs.phidata.com/templates/ai-api) diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/assistants/__init__.py b/ai/assistants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/assistants/pdf_auto.py b/ai/assistants/pdf_auto.py new file mode 100644 index 0000000..37e4ec6 --- /dev/null +++ b/ai/assistants/pdf_auto.py @@ -0,0 +1,38 @@ +from typing import Optional + +from phi.assistant import Assistant +from phi.llm.openai import OpenAIChat + +from ai.settings import ai_settings +from ai.storage import pdf_assistant_storage +from ai.knowledge_base import pdf_knowledge_base + + +def get_autonomous_pdf_assistant( + run_id: Optional[str] = None, + user_id: Optional[str] = None, + debug_mode: bool = False, +) -> Assistant: + """Get an Autonomous Assistant with a PDF knowledge base.""" + + return Assistant( + name="auto_pdf_assistant", + run_id=run_id, + user_id=user_id, + llm=OpenAIChat( + model=ai_settings.gpt_4, + max_tokens=ai_settings.default_max_tokens, + temperature=ai_settings.default_temperature, + ), + storage=pdf_assistant_storage, + knowledge_base=pdf_knowledge_base, + monitoring=True, + tool_calls=True, + show_tool_calls=True, + debug_mode=debug_mode, + description="You are a helpful assistant named 'phi' designed to answer questions about PDF contents.", + extra_instructions=[ + "Keep your answers under 5 sentences.", + ], + assistant_data={"assistant_type": "autonomous"}, + ) diff --git a/ai/assistants/pdf_rag.py b/ai/assistants/pdf_rag.py new file mode 100644 index 0000000..482d9fc --- /dev/null +++ b/ai/assistants/pdf_rag.py @@ -0,0 +1,40 @@ +from typing import Optional + +from phi.assistant import Assistant +from phi.llm.openai import OpenAIChat + +from ai.settings import ai_settings +from ai.storage import pdf_assistant_storage +from ai.knowledge_base import pdf_knowledge_base + + +def get_rag_pdf_assistant( + run_id: Optional[str] = None, + user_id: Optional[str] = None, + debug_mode: bool = False, +) -> Assistant: + """Get a RAG Assistant with a PDF knowledge base.""" + + return Assistant( + name="rag_pdf_assistant", + run_id=run_id, + user_id=user_id, + llm=OpenAIChat( + model=ai_settings.gpt_4, + max_tokens=ai_settings.default_max_tokens, + temperature=ai_settings.default_temperature, + ), + storage=pdf_assistant_storage, + knowledge_base=pdf_knowledge_base, + # This setting adds references from the knowledge_base to the user prompt + add_references_to_prompt=True, + # This setting adds the last 6 messages from the chat history to the API call + add_chat_history_to_messages=True, + monitoring=True, + debug_mode=debug_mode, + description="You are a helpful assistant named 'phi' designed to answer questions about PDF contents.", + extra_instructions=[ + "Keep your answers under 5 sentences.", + ], + assistant_data={"assistant_type": "rag"}, + ) diff --git a/ai/knowledge_base.py b/ai/knowledge_base.py new file mode 100644 index 0000000..cc356eb --- /dev/null +++ b/ai/knowledge_base.py @@ -0,0 +1,44 @@ +from phi.knowledge.combined import CombinedKnowledgeBase +from phi.knowledge.pdf import PDFUrlKnowledgeBase, PDFKnowledgeBase +from phi.vectordb.pgvector import PgVector + +from db.session import db_url + +url_pdf_knowledge_base = PDFUrlKnowledgeBase( + urls=["https://www.family-action.org.uk/content/uploads/2019/07/meals-more-recipes.pdf"], + # Store this knowledge base in ai.url_pdf_documents + vector_db=PgVector( + schema="ai", + db_url=db_url, + collection="url_pdf_documents", + ), + # 2 references are added to the prompt + num_documents=2, +) + +local_pdf_knowledge_base = PDFKnowledgeBase( + path="data/pdfs", + # Store this knowledge base in ai.local_pdf_documents + vector_db=PgVector( + schema="ai", + db_url=db_url, + collection="local_pdf_documents", + ), + # 3 references are added to the prompt + num_documents=3, +) + +pdf_knowledge_base = CombinedKnowledgeBase( + sources=[ + url_pdf_knowledge_base, + local_pdf_knowledge_base, + ], + # Store this knowledge base in ai.pdf_documents + vector_db=PgVector( + schema="ai", + db_url=db_url, + collection="pdf_documents", + ), + # 2 references are added to the prompt + num_documents=2, +) diff --git a/ai/settings.py b/ai/settings.py new file mode 100644 index 0000000..289bf24 --- /dev/null +++ b/ai/settings.py @@ -0,0 +1,18 @@ +from pydantic_settings import BaseSettings + + +class AISettings(BaseSettings): + """LLM settings that can be set using environment variables. + + Reference: https://pydantic-docs.helpmanual.io/usage/settings/ + """ + + gpt_4: str = "gpt-4-1106-preview" + gpt_3_5: str = "gpt-3.5-turbo-1106" + embedding_model: str = "text-embedding-ada-002" + default_max_tokens: int = 1024 + default_temperature: float = 0 + + +# Create AISettings object +ai_settings = AISettings() diff --git a/ai/storage.py b/ai/storage.py new file mode 100644 index 0000000..8e9a96c --- /dev/null +++ b/ai/storage.py @@ -0,0 +1,9 @@ +from phi.storage.assistant.postgres import PgAssistantStorage + +from db.session import db_url + +pdf_assistant_storage = PgAssistantStorage( + schema="ai", + db_url=db_url, + table_name="pdf_assistant", +) diff --git a/ai/test/__init__.py b/ai/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/test/assistant.py b/ai/test/assistant.py new file mode 100644 index 0000000..fff8f7d --- /dev/null +++ b/ai/test/assistant.py @@ -0,0 +1,8 @@ +from phi.assistant import Assistant + +assistant = Assistant(monitoring=True) + +# Stream is True by default +assistant.print_response("Tell me a 2 sentence horror story.") +# Set stream to False +# assistant.print_response("Tell me a 1 sentence horror story.", stream=False) diff --git a/ai/test/pdf_auto.py b/ai/test/pdf_auto.py new file mode 100644 index 0000000..10b6a23 --- /dev/null +++ b/ai/test/pdf_auto.py @@ -0,0 +1,11 @@ +from ai.assistants.pdf_auto import get_autonomous_pdf_assistant + +auto_pdf_assistant = get_autonomous_pdf_assistant() + +LOAD_KNOWLEDGE_BASE = True +if LOAD_KNOWLEDGE_BASE and auto_pdf_assistant.knowledge_base: + auto_pdf_assistant.knowledge_base.load(recreate=False) + +auto_pdf_assistant.print_response("Tell me about food safety?") +auto_pdf_assistant.print_response("How do I make chicken curry?") +auto_pdf_assistant.print_response("Summarize our conversation") diff --git a/ai/test/pdf_rag.py b/ai/test/pdf_rag.py new file mode 100644 index 0000000..30643f9 --- /dev/null +++ b/ai/test/pdf_rag.py @@ -0,0 +1,11 @@ +from ai.assistants.pdf_rag import get_rag_pdf_assistant + +rag_pdf_assistant = get_rag_pdf_assistant() + +LOAD_KNOWLEDGE_BASE = True +if LOAD_KNOWLEDGE_BASE and rag_pdf_assistant.knowledge_base: + rag_pdf_assistant.knowledge_base.load(recreate=False) + +rag_pdf_assistant.print_response("Tell me about food safety?") +rag_pdf_assistant.print_response("How do I make chicken curry?") +rag_pdf_assistant.print_response("Summarize our conversation") diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..01ce2fe --- /dev/null +++ b/api/main.py @@ -0,0 +1,40 @@ +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from api.settings import api_settings +from api.routes.v1_router import v1_router + + +def create_app() -> FastAPI: + """Create a FastAPI App + + Returns: + FastAPI: FastAPI App + """ + + # Create FastAPI App + app: FastAPI = FastAPI( + title=api_settings.title, + version=api_settings.version, + docs_url="/docs" if api_settings.docs_enabled else None, + redoc_url="/redoc" if api_settings.docs_enabled else None, + openapi_url="/openapi.json" if api_settings.docs_enabled else None, + ) + + # Add v1 router + app.include_router(v1_router) + + # Add Middlewares + app.add_middleware( + CORSMiddleware, + allow_origins=api_settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + return app + + +# Create FastAPI app +app = create_app() diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/assistants.py b/api/routes/assistants.py new file mode 100644 index 0000000..f3f1f34 --- /dev/null +++ b/api/routes/assistants.py @@ -0,0 +1,226 @@ +from typing import Generator, Optional, List, Dict, Any, Literal + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from phi.assistant import Assistant, AssistantRow +from pydantic import BaseModel + +from api.routes.endpoints import endpoints +from ai.assistants.pdf_rag import get_rag_pdf_assistant +from ai.assistants.pdf_auto import get_autonomous_pdf_assistant +from ai.storage import pdf_assistant_storage +from utils.log import logger + +###################################################### +## Router for PDF Assistants +###################################################### + +assistants_router = APIRouter(prefix=endpoints.ASSISTANTS, tags=["Assistants"]) +AssistantType = Literal["AUTO_PDF", "RAG_PDF"] + + +def get_assistant( + assistant_type: AssistantType, + run_id: Optional[str] = None, + user_id: Optional[str] = None, +): + """Return the assistant""" + + if assistant_type == "AUTO_PDF": + return get_autonomous_pdf_assistant(run_id=run_id, user_id=user_id) + elif assistant_type == "RAG_PDF": + return get_rag_pdf_assistant(run_id=run_id, user_id=user_id) + + +class LoadKnowledgeBaseRequest(BaseModel): + assistant: AssistantType = "RAG_PDF" + + +@assistants_router.post("/load-knowledge-base") +def load_knowledge_base(body: LoadKnowledgeBaseRequest): + """Loads the knowledge base for an Assistant""" + + assistant = get_assistant(assistant_type=body.assistant) + if assistant.knowledge_base: + assistant.knowledge_base.load(recreate=False) + return {"message": "Knowledge Base Loaded"} + + +class CreateRunRequest(BaseModel): + user_id: Optional[str] = None + assistant: AssistantType = "RAG_PDF" + + +class CreateRunResponse(BaseModel): + run_id: str + user_id: Optional[str] = None + chat_history: List[Dict[str, Any]] + + +@assistants_router.post("/create", response_model=CreateRunResponse) +def create_assistant_run(body: CreateRunRequest): + """Create a new Assistant run and returns the run_id""" + + logger.debug(f"CreateRunRequest: {body}") + assistant: Assistant = get_assistant(assistant_type=body.assistant, user_id=body.user_id) + + # create_run() will log the run in the database and return the run_id + # which is returned to the frontend to retrieve the run later + run_id: Optional[str] = assistant.create_run() + if run_id is None: + raise HTTPException(status_code=500, detail="Failed to create assistant run") + logger.debug(f"Created Assistant Run: {run_id}") + + return CreateRunResponse( + run_id=run_id, + user_id=assistant.user_id, + chat_history=assistant.memory.get_chat_history(), + ) + + +def chat_response_streamer(assistant: Assistant, message: str) -> Generator: + for chunk in assistant.run(message): + yield chunk + + +class ChatRequest(BaseModel): + message: str + stream: bool = True + run_id: Optional[str] = None + user_id: Optional[str] = None + assistant: AssistantType = "RAG_PDF" + + +@assistants_router.post("/chat") +def chat(body: ChatRequest): + """Sends a message to an Assistant and returns the response""" + + logger.debug(f"ChatRequest: {body}") + assistant: Assistant = get_assistant( + assistant_type=body.assistant, run_id=body.run_id, user_id=body.user_id + ) + + if body.stream: + return StreamingResponse( + chat_response_streamer(assistant, body.message), + media_type="text/event-stream", + ) + else: + return assistant.run(body.message, stream=False) + + +class ChatHistoryRequest(BaseModel): + run_id: str + user_id: Optional[str] = None + assistant: AssistantType = "RAG_PDF" + + +@assistants_router.post("/history", response_model=List[Dict[str, Any]]) +def get_chat_history(body: ChatHistoryRequest): + """Return the chat history for an Assistant run""" + + logger.debug(f"ChatHistoryRequest: {body}") + assistant: Assistant = get_assistant( + assistant_type=body.assistant, run_id=body.run_id, user_id=body.user_id + ) + # Load the assistant from the database + assistant.read_from_storage() + + return assistant.memory.get_chat_history() + + +class GetAssistantRunRequest(BaseModel): + run_id: str + user_id: Optional[str] = None + assistant: AssistantType = "RAG_PDF" + + +@assistants_router.post("/get", response_model=Optional[AssistantRow]) +def get_assistant_run(body: GetAssistantRunRequest): + """Returns the Assistant run""" + + logger.debug(f"GetAssistantRunRequest: {body}") + assistant: Assistant = get_assistant( + assistant_type=body.assistant, run_id=body.run_id, user_id=body.user_id + ) + + return assistant.read_from_storage() + + +class GetAllAssistantRunsRequest(BaseModel): + user_id: str + + +@assistants_router.post("/get-all", response_model=List[AssistantRow]) +def get_assistants(body: GetAllAssistantRunsRequest): + """Return all Assistant runs for a user""" + + logger.debug(f"GetAllAssistantRunsRequest: {body}") + return pdf_assistant_storage.get_all_runs(user_id=body.user_id) + + +class GetAllAssistantRunIdsRequest(BaseModel): + user_id: str + + +@assistants_router.post("/get-all-ids", response_model=List[str]) +def get_run_ids(body: GetAllAssistantRunIdsRequest): + """Return all run_ids for a user""" + + logger.debug(f"GetAllAssistantRunIdsRequest: {body}") + return pdf_assistant_storage.get_all_run_ids(user_id=body.user_id) + + +class RenameAssistantRunRequest(BaseModel): + run_id: str + run_name: str + user_id: Optional[str] = None + assistant: AssistantType = "RAG_PDF" + + +class RenameAssistantRunResponse(BaseModel): + run_id: str + run_name: str + + +@assistants_router.post("/rename", response_model=RenameAssistantRunResponse) +def rename_assistant(body: RenameAssistantRunRequest): + """Rename an Assistant run""" + + logger.debug(f"RenameAssistantRunRequest: {body}") + assistant: Assistant = get_assistant( + assistant_type=body.assistant, run_id=body.run_id, user_id=body.user_id + ) + assistant.rename_run(body.run_name) + + return RenameAssistantRunResponse( + run_id=assistant.run_id, + run_name=assistant.run_name, + ) + + +class AutoRenameAssistantRunRequest(BaseModel): + run_id: str + user_id: Optional[str] = None + assistant: AssistantType = "RAG_PDF" + + +class AutoRenameAssistantRunResponse(BaseModel): + run_id: str + run_name: str + + +@assistants_router.post("/autorename", response_model=AutoRenameAssistantRunResponse) +def autorename_assistant(body: AutoRenameAssistantRunRequest): + """Rename a assistant using the LLM""" + + logger.debug(f"AutoRenameAssistantRunRequest: {body}") + assistant: Assistant = get_assistant( + assistant_type=body.assistant, run_id=body.run_id, user_id=body.user_id + ) + assistant.auto_rename_run() + + return RenameAssistantRunResponse( + run_id=assistant.run_id, + run_name=assistant.run_name, + ) diff --git a/api/routes/endpoints.py b/api/routes/endpoints.py new file mode 100644 index 0000000..835674b --- /dev/null +++ b/api/routes/endpoints.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class ApiEndpoints: + PING: str = "/ping" + HEALTH: str = "/health" + ASSISTANTS: str = "/assistants" + + +endpoints = ApiEndpoints() diff --git a/api/routes/status.py b/api/routes/status.py new file mode 100644 index 0000000..abdf371 --- /dev/null +++ b/api/routes/status.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter + +from api.routes.endpoints import endpoints +from utils.dttm import current_utc_str + +###################################################### +## Router for health checks +###################################################### + +status_router = APIRouter(tags=["Status"]) + + +@status_router.get(endpoints.PING) +def status_ping(): + """Ping the Api""" + + return {"ping": "pong"} + + +@status_router.get(endpoints.HEALTH) +def status_health(): + """Check the health of the Api""" + + return { + "status": "success", + "router": "status", + "path": endpoints.HEALTH, + "utc": current_utc_str(), + } diff --git a/api/routes/v1_router.py b/api/routes/v1_router.py new file mode 100644 index 0000000..64a8803 --- /dev/null +++ b/api/routes/v1_router.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from api.routes.status import status_router +from api.routes.assistants import assistants_router + +v1_router = APIRouter(prefix="/v1") +v1_router.include_router(status_router) +v1_router.include_router(assistants_router) diff --git a/api/settings.py b/api/settings.py new file mode 100644 index 0000000..c3fe8e9 --- /dev/null +++ b/api/settings.py @@ -0,0 +1,57 @@ +from typing import List, Optional + +from pydantic import field_validator, Field +from pydantic_settings import BaseSettings +from pydantic_core.core_schema import FieldValidationInfo + + +class ApiSettings(BaseSettings): + """Api settings that can be set using environment variables. + + Reference: https://pydantic-docs.helpmanual.io/usage/settings/ + """ + + # Api title and version + title: str = "ai-api" + version: str = "1.0" + + # Api runtime_env derived from the `runtime_env` environment variable. + # Valid values include "dev", "stg", "prd" + runtime_env: str = "dev" + + # Set to False to disable docs at /docs and /redoc + docs_enabled: bool = True + + # Cors origin list to allow requests from. + # This list is set using the set_cors_origin_list validator + # which uses the runtime_env variable to set the + # default cors origin list. + cors_origin_list: Optional[List[str]] = Field(None, validate_default=True) + + @field_validator("runtime_env") + def validate_runtime_env(cls, runtime_env): + """Validate runtime_env.""" + + valid_runtime_envs = ["dev", "stg", "prd"] + if runtime_env not in valid_runtime_envs: + raise ValueError(f"Invalid runtime_env: {runtime_env}") + + return runtime_env + + @field_validator("cors_origin_list", mode="before") + def set_cors_origin_list(cls, cors_origin_list, info: FieldValidationInfo): + valid_cors = cors_origin_list or [] + + # Add phidata to cors origin list + valid_cors.extend(["https://phidata.app", "https://www.phidata.app"]) + + runtime_env = info.data.get("runtime_env") + if runtime_env == "dev": + # 3000 is the default port for create-react-app + valid_cors.extend(["http://localhost", "http://localhost:3000"]) + + return valid_cors + + +# Create ApiSettings object +api_settings = ApiSettings() diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..80d0c85 --- /dev/null +++ b/db/README.md @@ -0,0 +1,64 @@ +## Running database migrations + +Steps to migrate the database using alembic: + +1. Add/update SqlAlchemy tables in the `db/tables` directory. +2. Import the SqlAlchemy class in the `db/tables/__init__.py` file. +3. Create a database revision using: `alembic -c db/alembic.ini revision --autogenerate -m "Revision Name"` +4. Migrate database using: `alembic -c db/alembic.ini upgrade head` + +> Note: Set Env Var `MIGRATE_DB = True` to run the database migration in the entrypoint script at container startup. + +Checkout the docs on [adding database tables](https://docs.phidata.com/day-2/database-tables). + +## Creat a database revision using alembic + +Run the alembic command to create a database migration in the dev container: + +```bash +docker exec -it ai-api-dev alembic -c db/alembic.ini revision --autogenerate -m "Initialize DB" +``` + +## Migrate development database + +Run the alembic command to migrate the dev database: + +```bash +docker exec -it ai-api-dev alembic -c db/alembic.ini upgrade head +``` + +## Migrate production database + +1. Recommended: Set Env Var `MIGRATE_DB = True` which runs `alembic -c db/alembic.ini upgrade head` from the entrypoint script at container startup. +2. **OR** you can SSH into the production container to run the migration manually + +```bash +ECS_CLUSTER=ai-api-prd-cluster +TASK_ARN=$(aws ecs list-tasks --cluster ai-api-prd-cluster --query "taskArns[0]" --output text) +CONTAINER_NAME=ai-api-prd + +aws ecs execute-command --cluster $ECS_CLUSTER \ + --task $TASK_ARN \ + --container $CONTAINER_NAME \ + --interactive \ + --command "alembic -c db/alembic.ini upgrade head" +``` + +--- + +## How to create the migrations directory + +> This has already been run and is described here for completeness + +```bash +docker exec -it ai-api-dev zsh + +cd db +alembic init migrations +``` + +- After running the above commands, the `db/migrations` directory should be created. +- Update `alembic.ini` + - set `script_location = db/migrations` + - uncomment `black` hook in `[post_write_hooks]` +- Update `migrations/env.py` file following [this link](https://alembic.sqlalchemy.org/en/latest/autogenerate.html) diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/alembic.ini b/db/alembic.ini new file mode 100644 index 0000000..8f8bfc3 --- /dev/null +++ b/db/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = db/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +hooks = black +black.type = console_scripts +black.entrypoint = black +black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/db/migrations/README b/db/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/db/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/db/migrations/env.py b/db/migrations/env.py new file mode 100644 index 0000000..ff9f8d0 --- /dev/null +++ b/db/migrations/env.py @@ -0,0 +1,92 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from db.tables import Base +from db.session import db_url + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", db_url) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + + +# -*- Only include tables that are in the target_metadata +# See: https://alembic.sqlalchemy.org/en/latest/autogenerate.html#omitting-table-names-from-the-autogenerate-process +def include_name(name, type_, parent_names): + if type_ == "table": + return name in target_metadata.tables + else: + return True + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + include_name=include_name, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + version_table_schema=target_metadata.schema, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + include_name=include_name, + version_table_schema=target_metadata.schema, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/db/migrations/script.py.mako b/db/migrations/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/db/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/db/migrations/versions/.gitkeep b/db/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/session.py b/db/session.py new file mode 100644 index 0000000..47e7048 --- /dev/null +++ b/db/session.py @@ -0,0 +1,26 @@ +from sqlalchemy.engine import Engine, create_engine +from sqlalchemy.orm import Session, sessionmaker + +from db.settings import db_settings + +# Create SQLAlchemy Engine using a database URL +db_url = db_settings.get_db_url() +db_engine: Engine = create_engine(db_url, pool_pre_ping=True) + +# Create a SessionLocal class +# https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-sessionlocal-class +SessionLocal: sessionmaker[Session] = sessionmaker(autocommit=False, autoflush=False, bind=db_engine) + + +def get_db(): + """ + Dependency to get a database session. + + https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency + """ + + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/db/settings.py b/db/settings.py new file mode 100644 index 0000000..93ea4bf --- /dev/null +++ b/db/settings.py @@ -0,0 +1,50 @@ +from os import getenv +from typing import Optional + +from pydantic_settings import BaseSettings + +from utils.log import logger + + +class DbSettings(BaseSettings): + """Database settings that can be set using environment variables. + + Reference: https://docs.pydantic.dev/latest/usage/pydantic_settings/ + """ + + # Database configuration + db_host: Optional[str] = None + db_port: Optional[int] = None + db_user: Optional[str] = None + db_pass: Optional[str] = None + db_database: Optional[str] = None + db_driver: str = "postgresql+psycopg" + # Create/Upgrade database on startup using alembic + migrate_db: bool = False + + def get_db_url(self) -> str: + db_url = "{}://{}{}@{}:{}/{}".format( + self.db_driver, + self.db_user, + f":{self.db_pass}" if self.db_pass else "", + self.db_host, + self.db_port, + self.db_database, + ) + # Use local database if RUNTIME_ENV is not set + if "None" in db_url and getenv("RUNTIME_ENV") is None: + from workspace.dev_resources import dev_db + + logger.debug("Using local connection") + local_db_url = dev_db.get_db_connection_local() + if local_db_url: + db_url = local_db_url + + # Validate database connection + if "None" in db_url or db_url is None: + raise ValueError("Could not build database connection") + return db_url + + +# Create DbSettings object +db_settings = DbSettings() diff --git a/db/tables/__init__.py b/db/tables/__init__.py new file mode 100644 index 0000000..3221ba7 --- /dev/null +++ b/db/tables/__init__.py @@ -0,0 +1 @@ +from db.tables.base import Base diff --git a/db/tables/base.py b/db/tables/base.py new file mode 100644 index 0000000..9d9cd49 --- /dev/null +++ b/db/tables/base.py @@ -0,0 +1,13 @@ +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """ + Base class for SQLAlchemy model definitions. + + https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-base-class + https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.DeclarativeBase + """ + + metadata = MetaData(schema="public") diff --git a/example.env b/example.env new file mode 100644 index 0000000..d1423db --- /dev/null +++ b/example.env @@ -0,0 +1,5 @@ +# IMAGE_REPO=repo +# BUILD_IMAGES=True +# PUSH_IMAGES=True +# AWS_PROFILE=ai-demos +# OPENAI_API_KEY=sk-*** diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..234af25 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[project] +name = "ai-api" +version = "0.1.0" +requires-python = ">3.7" +readme = "README.md" +authors = [{ name = "Team", email = "team@team.com" }] + +dependencies = [ + # Api server Libraries + "fastapi", + "typer", + "uvicorn", + # Database Libraries + "alembic", + "pgvector", + "psycopg[binary]", + "sqlalchemy", + # Project libraries + "openai", + "pypdf", + "tiktoken", + "beautifulsoup4", + "types-beautifulsoup4", + # Type checking + "mypy", + # Testing + "pytest", + # Linting and Formatting + "ruff", + # phidata + "phidata==2.2.0", +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] + +# Change this value to use a different directory for the phidata workspace. +# [tool.phidata] +# workspace = "workspace" + +[tool.ruff] +line-length = 110 +exclude = ["aienv*", ".venv*"] +[tool.ruff.per-file-ignores] +# Ignore `F401` (import violations) in all `__init__.py` files +"__init__.py" = ["F401"] + +[tool.mypy] +check_untyped_defs = true +no_implicit_optional = true +warn_unused_configs = true +plugins = ["pydantic.mypy"] +exclude = ["aienv*", ".venv*"] + +[[tool.mypy.overrides]] +module = ["pgvector.*", "setuptools.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = "tests" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c76c94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,69 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# ./scripts/upgrade.sh all +# +alembic==1.13.1 +annotated-types==0.6.0 +anyio==4.2.0 +beautifulsoup4==4.12.2 +boto3==1.34.19 +botocore==1.34.19 +certifi==2023.11.17 +charset-normalizer==3.3.2 +click==8.1.7 +distro==1.9.0 +docker==7.0.0 +fastapi==0.109.0 +gitdb==4.0.11 +gitpython==3.1.41 +h11==0.14.0 +httpcore==1.0.2 +httpx==0.26.0 +idna==3.6 +iniconfig==2.0.0 +jmespath==1.0.1 +mako==1.3.0 +markdown-it-py==3.0.0 +markupsafe==2.1.3 +mdurl==0.1.2 +mypy==1.8.0 +mypy-extensions==1.0.0 +numpy==1.26.3 +openai==1.7.2 +packaging==23.2 +pgvector==0.2.4 +phidata==2.2.0 +pluggy==1.3.0 +psycopg[binary]==3.1.17 +psycopg-binary==3.1.17 +pydantic==2.3.0 +pydantic-core==2.6.3 +pydantic-settings==2.1.0 +pygments==2.17.2 +pypdf==3.17.4 +pytest==7.4.4 +python-dateutil==2.8.2 +python-dotenv==1.0.0 +pyyaml==6.0.1 +regex==2023.12.25 +requests==2.31.0 +rich==13.7.0 +ruff==0.1.13 +s3transfer==0.10.0 +six==1.16.0 +smmap==5.0.1 +sniffio==1.3.0 +soupsieve==2.5 +sqlalchemy==2.0.25 +starlette==0.35.1 +tiktoken==0.5.2 +tomli==2.0.1 +tqdm==4.66.1 +typer==0.9.0 +types-beautifulsoup4==4.12.0.20240106 +types-html5lib==1.1.11.20240106 +typing-extensions==4.9.0 +urllib3==2.0.7 +uvicorn==0.25.0 diff --git a/scripts/_utils.sh b/scripts/_utils.sh new file mode 100755 index 0000000..f5f0a60 --- /dev/null +++ b/scripts/_utils.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +############################################################################ +# +# Helper functions to import in other scripts +# +############################################################################ + +print_horizontal_line() { + echo "------------------------------------------------------------" +} + +print_heading() { + print_horizontal_line + echo "-*- $1" + print_horizontal_line +} + +print_status() { + echo "-*- $1" +} diff --git a/scripts/auth_ecr.sh b/scripts/auth_ecr.sh new file mode 100755 index 0000000..4e004ad --- /dev/null +++ b/scripts/auth_ecr.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +# Authenticate with ecr +aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [AWS_ACCOUNT].dkr.ecr.us-east-1.amazonaws.com diff --git a/scripts/build_dev_image.sh b/scripts/build_dev_image.sh new file mode 100755 index 0000000..990b2a0 --- /dev/null +++ b/scripts/build_dev_image.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WS_ROOT="$(dirname ${CURR_DIR})" +DOCKERFILE="Dockerfile" +REPO="repo" +NAME="ai-api" +TAG="dev" + +# Run docker buildx create --use before running this script +echo "Running: docker buildx build --platform=linux/amd64,linux/arm64 -t $REPO/$NAME:$TAG -f $DOCKERFILE $WS_ROOT --push" +docker buildx build --platform=linux/amd64,linux/arm64 -t $REPO/$NAME:$TAG -f $DOCKERFILE $WS_ROOT --push diff --git a/scripts/build_prd_image.sh b/scripts/build_prd_image.sh new file mode 100755 index 0000000..4d6380f --- /dev/null +++ b/scripts/build_prd_image.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WS_ROOT="$(dirname ${CURR_DIR})" +DOCKERFILE="Dockerfile" +REPO="repo" +NAME="ai-api" +TAG="prd" + +# Run docker buildx create --use before running this script +echo "Running: docker buildx build --platform=linux/amd64,linux/arm64 -t $REPO/$NAME:$TAG -f $DOCKERFILE $WS_ROOT --push" +docker buildx build --platform=linux/amd64,linux/arm64 -t $REPO/$NAME:$TAG -f $DOCKERFILE $WS_ROOT --push diff --git a/scripts/create_venv.sh b/scripts/create_venv.sh new file mode 100755 index 0000000..cfce997 --- /dev/null +++ b/scripts/create_venv.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +############################################################################ +# +# Create Virtual Environment & Install Requirements +# Usage: +# ./scripts/create_venv.sh +############################################################################ + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname ${CURR_DIR})" +VENV_DIR="${REPO_ROOT}/aienv" +source ${CURR_DIR}/_utils.sh + +main() { + print_heading "Setup: ${VENV_DIR}" + + print_status "Removing existing venv: ${VENV_DIR}" + rm -rf ${VENV_DIR} + + print_status "Creating python3 venv: ${VENV_DIR}" + python3 -m venv ${VENV_DIR} + + # Install workspace + source ${VENV_DIR}/bin/activate + source ${CURR_DIR}/install.sh + + print_heading "Activate using: source aienv/bin/activate" +} + +main "$@" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..31f6cb0 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +############################################################################ +# +# Entrypoint script +# +############################################################################ + +if [[ "$PRINT_ENV_ON_LOAD" = true || "$PRINT_ENV_ON_LOAD" = True ]]; then + echo "==================================================" + printenv + echo "==================================================" +fi + +############################################################################ +# Wait for Services +############################################################################ + +if [[ "$WAIT_FOR_DB" = true || "$WAIT_FOR_DB" = True ]]; then + dockerize \ + -wait tcp://$DB_HOST:$DB_PORT \ + -timeout 300s +fi + +if [[ "$WAIT_FOR_REDIS" = true || "$WAIT_FOR_REDIS" = True ]]; then + dockerize \ + -wait tcp://$REDIS_HOST:$REDIS_PORT \ + -timeout 300s +fi + +############################################################################ +# Install dependencies +############################################################################ + +if [[ "$INSTALL_REQUIREMENTS" = true || "$INSTALL_REQUIREMENTS" = True ]]; then + echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++" + echo "Installing requirements: $REQUIREMENTS_FILE_PATH" + pip3 install -r $REQUIREMENTS_FILE_PATH + echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++" +fi + +############################################################################ +# Migrate database +############################################################################ + +if [[ "$MIGRATE_DB" = true || "$MIGRATE_DB" = True ]]; then + echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++" + echo "Migrating Database" + alembic -c db/alembic.ini upgrade head + echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++" +fi + +############################################################################ +# Start App +############################################################################ + +case "$1" in + chill) + ;; + *) + echo "Running: $@" + exec "$@" + ;; +esac + +echo ">>> Hello World!" +while true; do sleep 18000; done diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..f86b918 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +############################################################################ +# +# Run this script to format using ruff +# Usage: +# ./scripts/format.sh +############################################################################ + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname $CURR_DIR)" +source ${CURR_DIR}/_utils.sh + +main() { + print_heading "Formatting workspace..." + print_heading "Running: ruff format ${REPO_ROOT}" + ruff format ${REPO_ROOT} +} + +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..df80b64 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +############################################################################ +# +# Install python dependencies. Run this inside a virtual env. +# Usage: +# 1. Create + activate virtual env using: +# python3 -m venv aienv +# source aienv/bin/activate +# 2. Install workspace and dependencies: +# ./scripts/install.sh +############################################################################ + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname $CURR_DIR)" +source ${CURR_DIR}/_utils.sh + +main() { + print_heading "Installing workspace: ${REPO_ROOT}" + + pip install --upgrade wheel + + print_heading "Installing requirements.txt" + pip install --no-deps \ + -r ${REPO_ROOT}/requirements.txt --no-cache + + print_heading "Installing workspace ${REPO_ROOT}" + pip install --editable "${REPO_ROOT}" +} + +main "$@" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100755 index 0000000..3bc12d1 --- /dev/null +++ b/scripts/upgrade.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +############################################################################ +# +# Upgrade python dependencies. Run this inside a virtual env. +# Usage: +# 1. Create + activate virtual env using: +# python3 -m venv aienv +# source aienv/bin/activate +# 2. Update dependencies from pyproject.toml: +# ./scripts/upgrade.sh: +# - Updates requirements.txt with any new dependencies added to pyproject.toml +# 3. Upgrade all python modules to latest version: +# ./scripts/upgrade.sh all: +# - Upgrade all packages in pyproject.toml to latest pinned version +############################################################################ + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname $CURR_DIR)" +source ${CURR_DIR}/_utils.sh + +main() { + UPGRADE_ALL=0 + + if [[ "$#" -eq 1 ]] && [[ "$1" = "all" ]]; then + UPGRADE_ALL=1 + fi + + print_heading "Upgrading dependencies for workspace: ${REPO_ROOT}" + print_heading "Installing pip & pip-tools" + python -m pip install --upgrade pip pip-tools + + cd ${REPO_ROOT} + if [[ UPGRADE_ALL -eq 1 ]]; + then + print_heading "Upgrading all dependencies to latest version" + CUSTOM_COMPILE_COMMAND="./scripts/upgrade.sh all" \ + pip-compile --upgrade --no-annotate --pip-args "--no-cache-dir" \ + -o ${REPO_ROOT}/requirements.txt \ + ${REPO_ROOT}/pyproject.toml + print_horizontal_line + else + print_heading "Updating requirements.txt" + CUSTOM_COMPILE_COMMAND="./scripts/upgrade.sh" \ + pip-compile --no-annotate --pip-args "--no-cache-dir" \ + -o ${REPO_ROOT}/requirements.txt \ + ${REPO_ROOT}/pyproject.toml + print_horizontal_line + fi +} + +main "$@" diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..b6db0cd --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +############################################################################ +# +# Run this script to validate the workspace: +# 1. Type check using mypy +# 2. Test using pytest +# 3. Lint using ruff +# Usage: +# ./scripts/validate.sh +############################################################################ + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname $CURR_DIR)" +source ${CURR_DIR}/_utils.sh + +main() { + print_heading "Validating workspace..." + print_heading "Running: mypy ${REPO_ROOT}" + mypy ${REPO_ROOT} + print_heading "Running: pytest ${REPO_ROOT}" + pytest ${REPO_ROOT} + print_heading "Running: ruff check ${REPO_ROOT}" + ruff check ${REPO_ROOT} +} + +main "$@" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..360a04b --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +# A minimal setup.py file for supporting editable installs + +from setuptools import setup + +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py new file mode 100644 index 0000000..3ada1ee --- /dev/null +++ b/tests/test_placeholder.py @@ -0,0 +1,2 @@ +def test_placeholder(): + assert True diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/dttm.py b/utils/dttm.py new file mode 100644 index 0000000..d344899 --- /dev/null +++ b/utils/dttm.py @@ -0,0 +1,9 @@ +from datetime import datetime, timezone + + +def current_utc() -> datetime: + return datetime.now(timezone.utc) + + +def current_utc_str() -> str: + return current_utc().strftime("%Y-%m-%dT%H:%M:%S") diff --git a/utils/log.py b/utils/log.py new file mode 100644 index 0000000..4e551e9 --- /dev/null +++ b/utils/log.py @@ -0,0 +1,22 @@ +import logging + + +def build_logger(logger_name: str) -> logging.Logger: + from rich.logging import RichHandler + + rich_handler = RichHandler(show_time=False, rich_tracebacks=False, tracebacks_show_locals=False) + rich_handler.setFormatter( + logging.Formatter( + fmt="%(message)s", + datefmt="[%X]", + ) + ) + + _logger = logging.getLogger(logger_name) + _logger.addHandler(rich_handler) + _logger.setLevel(logging.INFO) + _logger.propagate = False + return _logger + + +logger: logging.Logger = build_logger("ai-api") diff --git a/workspace/.gitignore b/workspace/.gitignore new file mode 100644 index 0000000..ec9d076 --- /dev/null +++ b/workspace/.gitignore @@ -0,0 +1,8 @@ +# ignore inputs +inputs + +# ignore outputs +output + +# ignore secrets +secrets diff --git a/workspace/__init__.py b/workspace/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workspace/dev_resources.py b/workspace/dev_resources.py new file mode 100644 index 0000000..ab7af83 --- /dev/null +++ b/workspace/dev_resources.py @@ -0,0 +1,76 @@ +from os import getenv + +from phi.docker.app.fastapi import FastApi +from phi.docker.app.postgres import PgVectorDb +from phi.docker.resource.image import DockerImage +from phi.docker.resources import DockerResources + +from workspace.settings import ws_settings + +# +# -*- Resources for the Development Environment +# + +# -*- Dev image +dev_image = DockerImage( + name=f"{ws_settings.image_repo}/{ws_settings.ws_name}", + tag=ws_settings.dev_env, + enabled=ws_settings.build_images, + path=str(ws_settings.ws_root), + pull=ws_settings.force_pull_images, + # Uncomment to push the dev image + # push_image=ws_settings.push_images, + skip_docker_cache=ws_settings.skip_image_cache, +) + +# -*- Dev database running on port 5432:5432 +dev_db = PgVectorDb( + name=f"{ws_settings.ws_name}-db", + enabled=ws_settings.dev_db_enabled, + pg_user="api", + pg_password="api", + pg_database="api", + # Connect to this db on port 5432 + host_port=5432, + debug_mode=True, +) + +# -*- Build container environment +container_env = { + "RUNTIME_ENV": "dev", + # Get the OpenAI API key from the local environment + "OPENAI_API_KEY": getenv("OPENAI_API_KEY"), + # Database configuration + "DB_HOST": dev_db.get_db_host(), + "DB_PORT": dev_db.get_db_port(), + "DB_USER": dev_db.get_db_user(), + "DB_PASS": dev_db.get_db_password(), + "DB_DATABASE": dev_db.get_db_database(), + # Wait for database to be available before starting the application + "WAIT_FOR_DB": ws_settings.dev_db_enabled, + # Migrate database on startup using alembic + # "MIGRATE_DB": ws_settings.prd_db_enabled, +} + +# -*- FastApi running on port 8000:8000 +dev_fastapi = FastApi( + name=ws_settings.ws_name, + enabled=ws_settings.dev_api_enabled, + image=dev_image, + command="uvicorn api.main:app --reload", + port_number=8000, + debug_mode=True, + mount_workspace=True, + env_vars=container_env, + use_cache=ws_settings.use_cache, + # Read secrets from secrets/dev_api_secrets.yml + secrets_file=ws_settings.ws_root.joinpath("workspace/secrets/dev_api_secrets.yml"), + depends_on=[dev_db], +) + +# -*- Dev DockerResources +dev_docker_resources = DockerResources( + env=ws_settings.dev_env, + network=ws_settings.ws_name, + apps=[dev_db, dev_fastapi], +) diff --git a/workspace/example_secrets/dev_api_secrets.yml b/workspace/example_secrets/dev_api_secrets.yml new file mode 100644 index 0000000..3c0570c --- /dev/null +++ b/workspace/example_secrets/dev_api_secrets.yml @@ -0,0 +1,6 @@ +SECRET_KEY: "very_secret" +# OPENAI_API_KEY: "sk-***" + +# AWS credentials +# AWS_ACCESS_KEY_ID: "AWS_ACCESS_KEY_ID" +# AWS_SECRET_ACCESS_KEY: "AWS_SECRET_ACCESS_KEY" diff --git a/workspace/example_secrets/prd_api_secrets.yml b/workspace/example_secrets/prd_api_secrets.yml new file mode 100644 index 0000000..d027981 --- /dev/null +++ b/workspace/example_secrets/prd_api_secrets.yml @@ -0,0 +1,2 @@ +SECRET_KEY: "very_secret" +# OPENAI_API_KEY: "sk-***" diff --git a/workspace/example_secrets/prd_db_secrets.yml b/workspace/example_secrets/prd_db_secrets.yml new file mode 100644 index 0000000..95afad8 --- /dev/null +++ b/workspace/example_secrets/prd_db_secrets.yml @@ -0,0 +1,3 @@ +# Secrets used by RDS Database +MASTER_USERNAME: api +MASTER_USER_PASSWORD: "api9999!!" diff --git a/workspace/prd_resources.py b/workspace/prd_resources.py new file mode 100644 index 0000000..5fdae08 --- /dev/null +++ b/workspace/prd_resources.py @@ -0,0 +1,237 @@ +from os import getenv + +from phi.aws.app.fastapi import FastApi +from phi.aws.resources import AwsResources +from phi.aws.resource.ecs import EcsCluster +from phi.aws.resource.ec2 import SecurityGroup, InboundRule +from phi.aws.resource.rds import DbInstance, DbSubnetGroup +from phi.aws.resource.reference import AwsReference +from phi.aws.resource.s3 import S3Bucket +from phi.aws.resource.secret import SecretsManager +from phi.docker.resources import DockerResources +from phi.docker.resource.image import DockerImage + +from workspace.settings import ws_settings + +# +# -*- Resources for the Production Environment +# +# Skip resource deletion when running `phi ws down` (set to True after initial deployment) +skip_delete: bool = False +# Save resource outputs to workspace/outputs +save_output: bool = True +# Create load balancer for the application +create_load_balancer: bool = True + +# -*- Production image +prd_image = DockerImage( + name=f"{ws_settings.image_repo}/{ws_settings.ws_name}", + tag=ws_settings.prd_env, + enabled=ws_settings.build_images, + path=str(ws_settings.ws_root), + platform="linux/amd64", + pull=ws_settings.force_pull_images, + push_image=ws_settings.push_images, + skip_docker_cache=ws_settings.skip_image_cache, +) + +# -*- S3 bucket for production data (set enabled=True when needed) +prd_bucket = S3Bucket( + name=f"{ws_settings.prd_key}-data", + enabled=False, + acl="private", + skip_delete=skip_delete, + save_output=save_output, +) + +# -*- Secrets for production application +prd_secret = SecretsManager( + name=f"{ws_settings.prd_key}-secret", + group="api", + # Create secret from workspace/secrets/prd_api_secrets.yml + secret_files=[ws_settings.ws_root.joinpath("workspace/secrets/prd_api_secrets.yml")], + skip_delete=skip_delete, + save_output=save_output, +) +# -*- Secrets for production database +prd_db_secret = SecretsManager( + name=f"{ws_settings.prd_key}-db-secret", + group="db", + # Create secret from workspace/secrets/prd_db_secrets.yml + secret_files=[ws_settings.ws_root.joinpath("workspace/secrets/prd_db_secrets.yml")], + skip_delete=skip_delete, + save_output=save_output, +) + +# -*- Security Group for the load balancer +prd_lb_sg = SecurityGroup( + name=f"{ws_settings.prd_key}-lb-security-group", + enabled=create_load_balancer, + group="api", + description="Security group for the load balancer", + inbound_rules=[ + InboundRule( + description="Allow HTTP traffic from the internet", + port=80, + cidr_ip="0.0.0.0/0", + ), + InboundRule( + description="Allow HTTPS traffic from the internet", + port=443, + cidr_ip="0.0.0.0/0", + ), + ], + skip_delete=skip_delete, + save_output=save_output, +) +# -*- Security Group for the application +prd_sg = SecurityGroup( + name=f"{ws_settings.prd_key}-security-group", + enabled=ws_settings.prd_api_enabled, + group="api", + description="Security group for the production api", + inbound_rules=[ + InboundRule( + description="Allow traffic from LB to the FastAPI server", + port=8000, + security_group_id=AwsReference(prd_lb_sg.get_security_group_id), + ), + ], + depends_on=[prd_lb_sg], + skip_delete=skip_delete, + save_output=save_output, +) +# -*- Security Group for the database +prd_db_port = 5432 +prd_db_sg = SecurityGroup( + name=f"{ws_settings.prd_key}-db-security-group", + enabled=ws_settings.prd_db_enabled, + group="db", + description="Security group for the production database", + inbound_rules=[ + InboundRule( + description="Allow traffic from the FastAPI server to the database", + port=prd_db_port, + security_group_id=AwsReference(prd_sg.get_security_group_id), + ), + ], + depends_on=[prd_sg], + skip_delete=skip_delete, + save_output=save_output, +) + +# -*- RDS Database Subnet Group +prd_db_subnet_group = DbSubnetGroup( + name=f"{ws_settings.prd_key}-db-sg", + enabled=ws_settings.prd_db_enabled, + group="db", + subnet_ids=ws_settings.subnet_ids, + skip_delete=skip_delete, + save_output=save_output, +) + +# -*- RDS Database Instance +prd_db = DbInstance( + name=f"{ws_settings.prd_key}-db", + enabled=ws_settings.prd_db_enabled, + group="db", + db_name="api", + port=prd_db_port, + engine="postgres", + engine_version="16.1", + allocated_storage=64, + # NOTE: For production, use a larger instance type. + # Last checked price: $0.0650 hourly = ~$50 per month + db_instance_class="db.t4g.medium", + db_security_groups=[prd_db_sg], + db_subnet_group=prd_db_subnet_group, + availability_zone=ws_settings.aws_az1, + publicly_accessible=False, + enable_performance_insights=True, + aws_secret=prd_db_secret, + skip_delete=skip_delete, + save_output=save_output, + # Do not wait for the db to be deleted + wait_for_delete=False, +) + +# -*- ECS cluster +launch_type = "FARGATE" +prd_ecs_cluster = EcsCluster( + name=f"{ws_settings.prd_key}-cluster", + ecs_cluster_name=ws_settings.prd_key, + capacity_providers=[launch_type], + skip_delete=skip_delete, + save_output=save_output, +) + +# -*- Build container environment +container_env = { + "RUNTIME_ENV": "prd", + # Get the OpenAI API key from the local environment + "OPENAI_API_KEY": getenv("OPENAI_API_KEY"), + # Database configuration + "DB_HOST": AwsReference(prd_db.get_db_endpoint), + "DB_PORT": AwsReference(prd_db.get_db_port), + "DB_USER": AwsReference(prd_db.get_master_username), + "DB_PASS": AwsReference(prd_db.get_master_user_password), + "DB_DATABASE": AwsReference(prd_db.get_db_name), + # Wait for database to be available before starting the application + "WAIT_FOR_DB": ws_settings.prd_db_enabled, + # Migrate database on startup using alembic + # "MIGRATE_DB": ws_settings.prd_db_enabled, +} + +# -*- FastApi running on ECS +prd_fastapi = FastApi( + name=ws_settings.prd_key, + enabled=ws_settings.prd_api_enabled, + group="api", + image=prd_image, + command="uvicorn api.main:app", + port_number=8000, + ecs_task_cpu="2048", + ecs_task_memory="4096", + ecs_service_count=1, + ecs_cluster=prd_ecs_cluster, + aws_secrets=[prd_secret], + subnets=ws_settings.subnet_ids, + security_groups=[prd_sg], + # To enable HTTPS, create an ACM certificate and add the ARN below: + # load_balancer_enable_https=True, + # load_balancer_certificate_arn="LOAD_BALANCER_CERTIFICATE_ARN", + load_balancer_security_groups=[prd_lb_sg], + create_load_balancer=create_load_balancer, + health_check_path="/v1/health", + env_vars=container_env, + use_cache=ws_settings.use_cache, + skip_delete=skip_delete, + save_output=save_output, + # Do not wait for the service to stabilize + wait_for_create=False, + # Do not wait for the service to be deleted + wait_for_delete=False, +) + +# -*- Production DockerResources +prd_docker_resources = DockerResources( + env=ws_settings.prd_env, + network=ws_settings.ws_name, + resources=[prd_image], +) + +# -*- Production AwsResources +prd_aws_config = AwsResources( + env=ws_settings.prd_env, + apps=[prd_fastapi], + resources=( + prd_lb_sg, + prd_sg, + prd_db_sg, + prd_secret, + prd_db_secret, + prd_db_subnet_group, + prd_db, + prd_bucket, + ), +) diff --git a/workspace/settings.py b/workspace/settings.py new file mode 100644 index 0000000..b91fc5b --- /dev/null +++ b/workspace/settings.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from phi.workspace.settings import WorkspaceSettings + +# +# -*- Define workspace settings using a WorkspaceSettings object +# these values can also be set using environment variables or a .env file +# +ws_settings = WorkspaceSettings( + # Workspace name: used for naming cloud resources + ws_name="ai-api", + # Path to the workspace root + ws_root=Path(__file__).parent.parent.resolve(), + # -*- Development env settings + dev_env="dev", + # -*- Development Apps + dev_api_enabled=True, + dev_db_enabled=True, + # -*- Production env settings + prd_env="prd", + # -*- Production Apps + prd_api_enabled=True, + prd_db_enabled=True, + # -*- AWS settings + # Region for AWS resources + aws_region="us-east-1", + # Availability Zones for AWS resources + aws_az1="us-east-1a", + aws_az2="us-east-1b", + # Subnet IDs in the aws_region + # subnet_ids=["subnet-xyz", "subnet-xyz"], + # -*- Image Settings + # Repository for images (for example, to use ECR use the following format) + # image_repo="[ACCOUNT_ID].dkr.ecr.us-east-1.amazonaws.com", + # Build images locally + # build_images=True, + # Push images after building + # push_images=True, +)