diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff8dfb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +my_examples +.idea +.venv +.DS_Store +select_ai.egg-info +dist +.ruff_cache +src/select_ai.egg-info +doc/.DS_Store +doc/build +doc/drawio +**/__pycache__ +test.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c7f995a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=600'] +- repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.4.2 + hooks: + - id: black +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.291 + hooks: + - id: ruff + args: ["check", "--select", "I", "--fix"] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85ab22a..1423b70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,3 @@ -*Detailed instructions on how to contribute to the project, if applicable. Must include section about Oracle Contributor Agreement with link and instructions* - # Contributing to this repository We welcome your contributions! There are multiple ways to contribute. @@ -48,6 +46,28 @@ can be accepted. your changes. Ensure that you reference the issue you created as well. 1. We will assign the pull request to 2-3 people for review before it is merged. + + +### Install development dependencies + +```bash +python3 -m venv .venv +source .venv/bin/activate + +python3 -m pip install --upgrade pip setuptools build pre-commit + +python3 -m pip install -e . # installs project in editable mode + +pre-commit install # install git hooks +pre-commit run --all-files +``` + +### Build + +```bash +python -m build +``` + ## Code of conduct Follow the [Golden Rule](https://en.wikipedia.org/wiki/Golden_Rule). If you'd diff --git a/LICENSE.txt b/LICENSE.txt index 9880d71..a117fc6 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2023 Oracle and/or its affiliates. +Copyright (c) 2025, Oracle and/or its affiliates. The Universal Permissive License (UPL), Version 1.0 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e42ab22 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include tests *.py +recursive-include samples *.py diff --git a/README.md b/README.md index fe93493..03ce307 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,24 @@ -*This repository acts as a template for all of Oracle’s GitHub repositories. It contains information about the guidelines for those repositories. All files and sections contained in this template are mandatory, and a GitHub app ensures alignment with these guidelines. To get started with a new repository, replace the italic paragraphs with the respective text for your project.* +# Select AI for Python -# Project name -*Describe your project's features, functionality and target audience* +Select AI for Python enables you to ask questions of your database data using natural language (text-to-SQL), get generative AI responses using your trusted content (retrieval augmented generation), generate synthetic data using large language models, and other features – all from Python. With the general availability of Select AI Python, Python developers have access to the functionality of Select AI on Oracle Autonomous Database. -## Installation - -*Provide detailed step-by-step installation instructions. You can name this section **How to Run** or **Getting Started** instead of **Installation** if that's more acceptable for your project* - -## Documentation +Select AI for Python enables you to leverage the broader Python ecosystem in combination with generative AI and database functionality - bridging the gap between the DBMS_CLOUD_AI PL/SQL package and Python's rich ecosystem. It provides intuitive objects and methods for AI model interaction. -*Developer-oriented documentation can be published on GitHub, but all product documentation must be published on * -## Examples +## Installation -*Describe any included examples or provide a link to a demo/tutorial* +Run +```bash +python3 -m pip install select_ai +``` -## Help +## Samples -*Inform users on where to get help or how to receive official support from Oracle (if applicable)* +Examples can be found in the samples directory ## Contributing -*If your project has specific contribution requirements, update the CONTRIBUTING.md file to ensure those requirements are clearly explained* This project welcomes contributions from the community. Before submitting a pull request, please [review our contribution guide](./CONTRIBUTING.md) @@ -32,13 +28,7 @@ Please consult the [security guide](./SECURITY.md) for our responsible security ## License -*The correct copyright notice format for both documentation and software is* - "Copyright (c) [year,] year Oracle and/or its affiliates." -*You must include the year the content was first released (on any platform) and the most recent year in which it was revised* - -Copyright (c) 2023 Oracle and/or its affiliates. - -*Replace this statement if your project is not licensed under the UPL* +Copyright (c) 2025 Oracle and/or its affiliates. Released under the Universal Permissive License v1.0 as shown at . diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..f1a3cc5 --- /dev/null +++ b/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,37 @@ +Third Party Dependencies: +========================= + +pandas +====== + +BSD 3-Clause License + +Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team +All rights reserved. + +Copyright (c) 2011-2025, Open source contributors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..553309b --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,31 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +.PHONY: html +html: + @$(SPHINXBUILD) -M html $(SOURCEDIR) $(BUILDDIR) $(SPHINXOPTS) + +.PHONY: epub +epub: + @$(SPHINXBUILD) -M epub $(SOURCEDIR) $(BUILDDIR) $(SPHINXOPTS) + +.PHONY: pdf +pdf: + @$(SPHINXBUILD) -M latexpdf $(SOURCEDIR) $(BUILDDIR) $(SPHINXOPTS) + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..9b3d4ce --- /dev/null +++ b/doc/README.md @@ -0,0 +1,24 @@ +Sphinx is used to generate documentation + +```text +python -m pip install -r requirements.txt +``` + +For more information on Sphinx, please visit this page: + +http://www.sphinx-doc.org + +Once Sphinx is installed, the supplied Makefile can be used to build the +different targets, for example to build the HTML documentation, run:: + + make + +To make ePub documentation, run:: + + make epub + +To make PDF documentation, run:: + + make pdf + +The program ``latexmk`` may be required by Sphinx to generate PDF output. diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..01892c7 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,3 @@ +sphinx +sphinx-rtd-theme +sphinx_toolbox diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..efc247e --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,58 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import os +import sys + +import sphinx_prompt + +sys.modules["sphinx-prompt"] = sphinx_prompt + +sys.path.insert(0, os.path.join("..", "..", "src", "select_ai")) + +autodocs_default_options = { + "members": True, + "inherited-members": True, + "undoc-members": True, +} + +project = "Select AI for Python" +copyright = "2025, Oracle and/or its affiliates. All rights reserved." +author = "Oracle" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ["sphinx.ext.autodoc", "sphinx_toolbox.latex"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The root toctree document. +root_doc = master_doc = "index" + + +templates_path = ["_templates"] +exclude_patterns = [] +global_vars = {} +local_vars = {} + +version_file_name = os.path.join("..", "..", "src", "select_ai", "version.py") +with open(version_file_name) as f: + exec(f.read(), global_vars, local_vars) +version = ".".join(local_vars["__version__"].split(".")[:2]) +# The full version, including alpha/beta/rc tags. +release = local_vars["__version__"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] + +pygments_style = "sphinx" diff --git a/doc/source/image/conversation.png b/doc/source/image/conversation.png new file mode 100644 index 0000000..1c22549 Binary files /dev/null and b/doc/source/image/conversation.png differ diff --git a/doc/source/image/profile_provider.png b/doc/source/image/profile_provider.png new file mode 100644 index 0000000..715c840 Binary files /dev/null and b/doc/source/image/profile_provider.png differ diff --git a/doc/source/image/vector_index.png b/doc/source/image/vector_index.png new file mode 100644 index 0000000..23b5a6a Binary files /dev/null and b/doc/source/image/vector_index.png differ diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..0d062d6 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,99 @@ +.. Python API for Select AI documentation master file, created by + sphinx-quickstart on Thu May 15 08:17:49 2025. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +select_ai documentation +====================================== + +``select_ai`` is a Python module which enables integrating `DBMS_CLOUD_AI `__ +``PL/SQL`` package into Python workflows. It bridges the gap between ``PL/SQL`` package's AI capabilities +and Python's rich ecosystem. + + +Getting Started +=============== + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/introduction.rst + user_guide/installation.rst + user_guide/connection.rst + + +Actions +======= + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/actions.rst + +Provider +======== + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/provider.rst + +Credential +========== + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/credential.rst + + +Profile Attributes +================== + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/profile_attributes.rst + +Profile +================== + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/profile.rst + + +Conversation +============ + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/conversation.rst + + +Vector Index +============ + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/vector_index.rst + +Synthetic Data +============== + +.. toctree:: + :numbered: + :maxdepth: 3 + + user_guide/synthetic_data.rst diff --git a/doc/source/license.rst b/doc/source/license.rst new file mode 100644 index 0000000..6f252d4 --- /dev/null +++ b/doc/source/license.rst @@ -0,0 +1,47 @@ +:orphan: + +.. _license: + + +.. include:: + +.. centered:: **LICENSE AGREEMENT FOR python-select-ai** + +Copyright (c) 2025, Oracle and/or its affiliates. + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and + +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a "Larger Work" to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: +The above copyright notice and either this complete permission notice or at +a minimum a reference to the UPL must 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. + +END OF TERMS AND CONDITIONS diff --git a/doc/source/user_guide/actions.rst b/doc/source/user_guide/actions.rst new file mode 100644 index 0000000..54fdd2e --- /dev/null +++ b/doc/source/user_guide/actions.rst @@ -0,0 +1,36 @@ +.. _actions: + +An action in Select AI is a keyword that instructs Select AI to perform different behavior when acting on the prompt. + +******************** +Supported Actions +******************** + +Following list of actions can be performed using ``select_ai`` + +.. list-table:: Select AI Actions + :header-rows: 1 + :widths: 20 30 50 + :align: left + + * - Actions + - Enum + - Description + * - chat + - ``select_ai.Action.CHAT`` + - Enables general conversations with the LLM, potentially for clarifying prompts, exploring data, or generating content. + * - explainsql + - ``select_ai.Action.EXPLAINSQL`` + - Explain the generated SQL query + * - narrate + - ``select_ai.Action.NARRATE`` + - Explains the output of the query in natural language, making the results accessible to users without deep technical expertise. + * - runsql + - ``select_ai.Action.RUNSQL`` + - Executes a SQL query generated from a natural language prompt. This is the default action. + * - showprompt + - ``select_ai.Action.SHOWPROMPT`` + - Show the details of the prompt sent to LLM + * - showsql + - ``select_ai.Action.SHOWSQL`` + - Displays the generated SQL statement without executing it. diff --git a/doc/source/user_guide/async_profile.rst b/doc/source/user_guide/async_profile.rst new file mode 100644 index 0000000..24d80d9 --- /dev/null +++ b/doc/source/user_guide/async_profile.rst @@ -0,0 +1,216 @@ +.. _async_profile: + +An AsyncProfile object can be created with ``select_ai.AsyncProfile()`` +``AsyncProfile`` support use of concurrent programming with `asyncio `__. +Unless explicitly noted as synchronous, the ``AsyncProfile`` methods should be +used with ``await``. + +******************** +``AsyncProfile`` API +******************** + +.. autoclass:: select_ai.AsyncProfile + :members: + +.. latex:clearpage:: + +*********************** +Async Profile creation +*********************** + +.. literalinclude:: ../../../samples/async/profile_create.py + :language: python + +output:: + + Created async profile async_oci_ai_profile + Profile attributes: {'annotations': None, + 'case_sensitive_values': None, + 'comments': None, + 'constraints': None, + 'conversation': None, + 'credential_name': 'my_oci_ai_profile_key', + 'enable_source_offsets': None, + 'enable_sources': None, + 'enforce_object_list': None, + 'max_tokens': '1024', + 'object_list': '[{"owner":"SH"}]', + 'object_list_mode': None, + 'provider': OCIGenAIProvider(embedding_model=None, + model=None, + provider_name='oci', + provider_endpoint=None, + region='us-chicago-1', + oci_apiformat='GENERIC', + oci_compartment_id=None, + oci_endpoint_id=None, + oci_runtimetype=None), + 'seed': None, + 'stop_tokens': None, + 'streaming': None, + 'temperature': None, + 'vector_index_name': None} + + +.. latex:clearpage:: + +*********************** +Async explain SQL +*********************** + +.. literalinclude:: ../../../samples/async/profile_explain_sql.py + :language: python + +output:: + + To answer the question "How many promotions", we need to write a SQL query that counts the number of rows in the "PROMOTIONS" table. Here is the query: + + ```sql + SELECT + COUNT("p"."PROMO_ID") AS "Number of Promotions" + FROM + "SH"."PROMOTIONS" "p"; + ``` + + Explanation: + + * We use the `COUNT` function to count the number of rows in the table. + * We use the table alias `"p"` to refer to the `"PROMOTIONS"` table. + * We enclose the table name and column name in double quotes to make them case-sensitive. + * We use the `AS` keyword to give an alias to the count column, making it easier to read. + + This query will return the total number of promotions in the `"PROMOTIONS"` table. + +.. latex:clearpage:: + +*********************** +Async run SQL +*********************** + +.. literalinclude:: ../../../samples/async/profile_run_sql.py + :language: python + +output:: + + PROMOTION_COUNT + 0 503 + +.. latex:clearpage:: + +*********************** +Async show SQL +*********************** + +.. literalinclude:: ../../../samples/async/profile_show_sql.py + :language: python + +output:: + + SELECT COUNT("p"."PROMO_ID") AS "PROMOTION_COUNT" FROM "SH"."PROMOTIONS" "p" + + +.. latex:clearpage:: + +*********************** +Async concurrent SQL +*********************** + +.. literalinclude:: ../../../samples/async/profile_sql_concurrent_tasks.py + :language: python + +output:: + + SELECT COUNT("c"."CUST_ID") AS "customer_count" FROM "SH"."CUSTOMERS" "c" + + To answer the question "How many promotions", we need to write a SQL query that counts the number of rows in the "PROMOTIONS" table. Here is the query: + + ```sql + SELECT + COUNT("p"."PROMO_ID") AS "number_of_promotions" + FROM + "SH"."PROMOTIONS" "p"; + ``` + + Explanation: + + * We use the `COUNT` function to count the number of rows in the table. + * We use the table alias `"p"` to refer to the `"PROMOTIONS"` table. + * We specify the schema name `"SH"` to ensure that we are accessing the correct table. + * We enclose the table name, schema name, and column name in double quotes to make them case-sensitive. + * The `AS` keyword is used to give an alias to the calculated column, in this case, `"number_of_promotions"`. + + This query will return the total number of promotions in the `"PROMOTIONS"` table. + + PROMOTION_COUNT + 0 503 + +.. latex:clearpage:: + +********** +Async chat +********** + +.. literalinclude:: ../../../samples/async/profile_chat.py + :language: python + +output:: + + OCI stands for several things depending on the context: + + 1. **Oracle Cloud Infrastructure (OCI)**: This is a cloud computing service offered by Oracle Corporation. It provides a range of services including computing, storage, networking, database, and more, allowing businesses to build, deploy, and manage applications and services in a secure and scalable manner. + + ... + .. + OML4PY provides a Python interface to OML, allowing users to create, manipulate, and analyze models using Python scripts. It enables users to leverage the power of OML and OMF from within Python, making it easier to integrate modeling and simulation into larger workflows and applications. + ... + ... + + An Autonomous Database is a type of database that uses artificial intelligence (AI) and machine learning (ML) to automate many of the tasks typically performed by a database administrator (DBA) + ... + ... + +.. latex:clearpage:: + +********************* +Async pipeline +********************* + +.. literalinclude:: ../../../samples/async/profile_pipeline.py + :language: python + +output:: + + Result 0 for prompt 'What is Oracle Autonomous Database?' is: Oracle Autonomous Database is a cloud-based database service that uses artificial intelligence (AI) and machine learning (ML) to automate many of the tasks associated with managing a database. It is a self-driving, self-securing, and self-repairing database that eliminates the need for manual database administration, allowing users to focus on higher-level tasks. + + + Result 1 for prompt 'Generate SQL to list all customers?' is: SELECT "c"."CUST_ID" AS "Customer ID", "c"."CUST_FIRST_NAME" AS "First Name", "c"."CUST_LAST_NAME" AS "Last Name", "c"."CUST_EMAIL" AS "Email" FROM "SH"."CUSTOMERS" "c" + + Result 2 for prompt 'Explain the query: SELECT * FROM sh.products' is: ```sql + SELECT + p.* + FROM + "SH"."PRODUCTS" p; + ``` + + **Explanation:** + + This query is designed to retrieve all columns (`*`) from the `"SH"."PRODUCTS"` table. + + Here's a breakdown of the query components: + + + Result 3 for prompt 'Explain the query: SELECT * FROM sh.products' is: ORA-20000: Invalid action - INVALID ACTION + +.. latex:clearpage:: + +**************************** +List profiles asynchronously +**************************** + +.. literalinclude:: ../../../samples/async/profiles_list.py + :language: python + +output:: + + OCI_VECTOR_AI_PROFILE + OCI_AI_PROFILE diff --git a/doc/source/user_guide/connection.rst b/doc/source/user_guide/connection.rst new file mode 100644 index 0000000..b03d556 --- /dev/null +++ b/doc/source/user_guide/connection.rst @@ -0,0 +1,50 @@ +.. _conn: + +***************************** +Connecting to Oracle Database +***************************** + +``select_ai`` uses the Python thin driver i.e. ``python-oracledb`` +to connect to the database and execute PL/SQL subprograms. + +.. _sync_conn: + +Synchronous connection +====================== + +To connect to an Oracle Database synchronously, use +``select_ai.connect()`` method as shown below + +.. code-block:: python + + import select_ai + + user = "" + password = "" + dsn = "" + select_ai.connect(user=user, password=password, dsn=dsn) + +.. _async_conn: + +Asynchronous connection +======================= + +Asynchronous applications should use ``select_ai.async_connect()`` along +with ``await`` keyword: + +.. code-block:: python + + import select_ai + + user = "" + password = "" + dsn = "" + await select_ai.async_connect(user=user, password=password, dsn=dsn) + + +.. note:: + + For m-TLS (wallet) based connection, additional parameters like + ``wallet_location``, ``wallet_password``, ``https_proxy``, + ``https_proxy_port`` can be passed to the ``connect`` and ``async_connect`` + methods diff --git a/doc/source/user_guide/conversation.rst b/doc/source/user_guide/conversation.rst new file mode 100644 index 0000000..e589a13 --- /dev/null +++ b/doc/source/user_guide/conversation.rst @@ -0,0 +1,142 @@ +.. _conversation: + +Conversations in Select AI represent an interactive exchange between the user +and the system, enabling users to query or interact with the database through +a series of natural language prompts. + +***************************** +``Conversation Object model`` +***************************** +.. _conversationfig: +.. figure:: /image/conversation.png + :alt: Select AI Conversation + +.. latex:clearpage:: + +************************** +``ConversationAttributes`` +************************** + +.. autoclass:: select_ai.ConversationAttributes + :members: + +.. latex:clearpage:: + +******************** +``Conversation`` API +******************** + +.. autoclass:: select_ai.Conversation + :members: + +.. latex:clearpage:: + +Create conversion +++++++++++++++++++ + +.. literalinclude:: ../../../samples/conversation_create.py + :language: python + +output:: + + Created conversation with conversation id: 3AB2ED3E-7E52-8000-E063-BE1A000A15B6 + +.. latex:clearpage:: + +Chat session ++++++++++++++ + +.. literalinclude:: ../../../samples/conversation_chat_session.py + :language: python + +output:: + + Conversation ID for this session is: 380A1910-5BF2-F7A1-E063-D81A000A3FDA + + The importance of the history of science lies in its ability to provide a comprehensive understanding of the development of scientific knowledge and its impact on society. Here are some key reasons why the history of science is important: + + 1. **Contextualizing Scientific Discoveries**: The history of science helps us understand the context in which scientific discoveries were made, including the social, cultural, and intellectual climate of the time. This context is essential for appreciating the significance and relevance of scientific findings. + + .. + .. + + The history of science is replete with examples of mistakes, errors, and misconceptions that have occurred over time. By studying these mistakes, scientists and researchers can gain valuable insights into the pitfalls and challenges that have shaped the development of scientific knowledge. Learning from past mistakes is essential for several reasons: + ... + ... + +.. latex:clearpage:: + +List conversations +++++++++++++++++++ + +.. literalinclude:: ../../../samples/conversations_list.py + :language: python + +output:: + + 5275A80-A290-DA17-E063-151B000AD3B4 + ConversationAttributes(title='History of Science', description="LLM's understanding of history of science", retention_days=7) + + 37DF777F-F3DA-F084-E063-D81A000A53BE + ConversationAttributes(title='History of Science', description="LLM's understanding of history of science", retention_days=7) + +.. latex:clearpage:: + +Delete conversation ++++++++++++++++++++ + +.. literalinclude:: ../../../samples/conversation_delete.py + :language: python + +output:: + + Deleted conversation with conversation id: 37DDC22E-11C8-3D49-E063-D81A000A85FE + + +.. latex:clearpage:: + +************************* +``AsyncConversation`` API +************************* + +.. autoclass:: select_ai.AsyncConversation + :members: + +.. latex:clearpage:: + +Async chat session +++++++++++++++++++ + +.. literalinclude:: ../../../samples/async/conversation_chat_session.py + :language: python + +output:: + + Conversation ID for this session is: 380A1910-5BF2-F7A1-E063-D81A000A3FDA + + The importance of the history of science lies in its ability to provide a comprehensive understanding of the development of scientific knowledge and its impact on society. Here are some key reasons why the history of science is important: + + 1. **Contextualizing Scientific Discoveries**: The history of science helps us understand the context in which scientific discoveries were made, including the social, cultural, and intellectual climate of the time. This context is essential for appreciating the significance and relevance of scientific findings. + + .. + .. + + The history of science is replete with examples of mistakes, errors, and misconceptions that have occurred over time. By studying these mistakes, scientists and researchers can gain valuable insights into the pitfalls and challenges that have shaped the development of scientific knowledge. Learning from past mistakes is essential for several reasons: + ... + ... + +.. latex:clearpage:: + +Async list conversations +++++++++++++++++++++++++ + +.. literalinclude:: ../../../samples/async/conversations_list.py + :language: python + +output:: + + 5275A80-A290-DA17-E063-151B000AD3B4 + ConversationAttributes(title='History of Science', description="LLM's understanding of history of science", retention_days=7) + + 37DF777F-F3DA-F084-E063-D81A000A53BE + ConversationAttributes(title='History of Science', description="LLM's understanding of history of science", retention_days=7) diff --git a/doc/source/user_guide/credential.rst b/doc/source/user_guide/credential.rst new file mode 100644 index 0000000..9f30879 --- /dev/null +++ b/doc/source/user_guide/credential.rst @@ -0,0 +1,47 @@ +.. _credential: + +Credential object securely stores API key from your AI provider for use by Oracle Database. +The following table shows AI Provider and corresponding credential object format + +.. list-table:: AI Provider and expected credential format + :header-rows: 1 + :widths: 30 70 + :align: left + + * - AI Provider + - Credential format + * - Anthropic + - .. code-block:: python + + {"username": "anthropic", "password": "sk-xxx"} + * - HuggingFace + - .. code-block:: python + + {"username": "hf", "password": "hf_xxx"} + * - OCI Gen AI + - .. code-block:: python + + {"user_ocid": "", "tenancy_ocid": "", "private_key": "", "fingerprint": ""} + * - OpenAI + - .. code-block:: python + + {"username": "openai", "password": "sk-xxx"} + + + + +.. latex:clearpage:: + +************************** +Create credential +************************** + +In this example, we create a credential object to authenticate to OCI Gen AI +service provider: + +.. literalinclude:: ../../../samples/create_ai_credential.py + :language: python + +output:: + + Created credential: my_oci_ai_profile_key diff --git a/doc/source/user_guide/installation.rst b/doc/source/user_guide/installation.rst new file mode 100644 index 0000000..bce96d4 --- /dev/null +++ b/doc/source/user_guide/installation.rst @@ -0,0 +1,70 @@ +.. _installation: + +*************************** +Installing ``select_ai`` +*************************** + +.. _installation_requirements: + +Installation requirements +========================== + +To use ``select_ai`` you need: + +- Python 3.9, 3.10, 3.11, 3.12 or 3.13 + +- ``python-oracledb`` - This package is automatically installed as a dependency requirement + +- ``pandas`` - This package is automatically installed as a dependency requirement + + +.. _quickstart: + +``select_ai`` installation +============================ + +``select_ai`` can be installed from Python's package repository +`PyPI `__ using +`pip `__. + +1. Install `Python 3 `__ if it is not already + available. Use any version from Python 3.9 through 3.13. + +2. Install ``select_ai``: + + .. code-block:: shell + + python3 -m pip install select_ai --upgrade --user + +3. If you are behind a proxy, use the ``--proxy`` option. For example: + + .. code-block:: shell + + python3 -m pip install select_ai --upgrade --user --proxy=http://proxy.example.com:80 + + +4. Create a file ``select_ai_connection_test.py`` such as: + + .. code-block:: python + + import select_ai + + user = "" + password = "" + dsn = "" + select_ai.connect(user=user, password=password, dsn=dsn) + print("Connected to the Database") + +5. Run ``select_ai_connection_test.py`` + + .. code-block:: shell + + python3 select_ai_connection_test.py + + Enter the database password when prompted and message will be shown: + + .. code-block:: shell + + Connected to the Database + +.. latex:clearpage:: diff --git a/doc/source/user_guide/introduction.rst b/doc/source/user_guide/introduction.rst new file mode 100644 index 0000000..dd5ad91 --- /dev/null +++ b/doc/source/user_guide/introduction.rst @@ -0,0 +1,18 @@ +.. _introduction: + +***************************************************** +Introduction to Select AI for Python +***************************************************** + +``select_ai`` is a Python module that helps you invoke `DBMS_CLOUD_AI `__ +using Python. It supports text-to-SQL generation, retrieval augmented generation +(RAG), synthetic data generation, and several other features using Oracle-based +and third-party AI providers. + +``select_ai`` supports both synchronous and concurrent(asynchronous) +programming styles. + +The Select AI Python API supports Python versions 3.9, 3.10, 3.11, 3.12 and +3.13. + +.. latex:clearpage:: diff --git a/doc/source/user_guide/profile.rst b/doc/source/user_guide/profile.rst new file mode 100644 index 0000000..a5a8cbd --- /dev/null +++ b/doc/source/user_guide/profile.rst @@ -0,0 +1,161 @@ +.. _profile: + +An AI profile is a specification that includes the AI provider to use and other +details regarding metadata and database objects required for generating +responses to natural language prompts. + +An AI profile object can be created using ``select_ai.Profile()`` + +******************** +Profile Object Model +******************** + +.. _profilefig: +.. figure:: /image/profile_provider.png + :alt: Select AI Profile and Providers + +.. latex:clearpage:: + +******************************* +Base ``Profile`` API +******************************* +.. autoclass:: select_ai.BaseProfile + :members: + +.. latex:clearpage:: + +******************************* +``Profile`` API +******************************* + +.. autoclass:: select_ai.Profile + :members: + +.. latex:clearpage:: + +************************** +Create Profile +************************** + +.. literalinclude:: ../../../samples/profile_create.py + :language: python + +output:: + + Created profile oci_ai_profile + Profile attributes are: {'annotations': None, + 'case_sensitive_values': None, + 'comments': None, + 'constraints': None, + 'conversation': None, + 'credential_name': 'my_oci_ai_profile_key', + 'enable_source_offsets': None, + 'enable_sources': None, + 'enforce_object_list': None, + 'max_tokens': '1024', + 'object_list': '[{"owner":"SH"}]', + 'object_list_mode': None, + 'provider': OCIGenAIProvider(embedding_model=None, + model=None, + provider_name='oci', + provider_endpoint=None, + region='us-chicago-1', + oci_apiformat='GENERIC', + oci_compartment_id=None, + oci_endpoint_id=None, + oci_runtimetype=None), + 'seed': None, + 'stop_tokens': None, + 'streaming': None, + 'temperature': None, + 'vector_index_name': None} + + +.. latex:clearpage:: + +************************** +Narrate +************************** + +.. literalinclude:: ../../../samples/profile_narrate.py + :language: python + +output:: + + There are 503 promotions in the database. + + +.. latex:clearpage:: + +************************** +Show SQL +************************** + +.. literalinclude:: ../../../samples/profile_show_sql.py + :language: python + +output:: + + SELECT + COUNT("p"."PROMO_ID") AS "Number of Promotions" + FROM "SH"."PROMOTIONS" "p" + +.. latex:clearpage:: + +************************** +Run SQL +************************** + +.. literalinclude:: ../../../samples/profile_run_sql.py + :language: python + +output:: + + Index(['Number of Promotions'], dtype='object') + Number of Promotions + 0 503 + + +.. latex:clearpage:: + +************************** +Chat +************************** + +.. literalinclude:: ../../../samples/profile_chat.py + :language: python + +output:: + + OCI stands for Oracle Cloud Infrastructure. It is a comprehensive cloud computing platform provided by Oracle Corporation that offers a wide range of services for computing, storage, networking, database, and more. + ... + ... + OCI competes with other major cloud providers, including Amazon Web Services (AWS), Microsoft Azure, Google Cloud Platform (GCP), and IBM Cloud. + +.. latex:clearpage:: + +************************** +List profiles +************************** + +.. literalinclude:: ../../../samples/profiles_list.py + :language: python + +output:: + + ASYNC_OCI_AI_PROFILE + OCI_VECTOR_AI_PROFILE + ASYNC_OCI_VECTOR_AI_PROFILE + OCI_AI_PROFILE + +.. latex:clearpage:: + +************* +Async Profile +************* + +.. toctree:: + :numbered: + :maxdepth: 3 + + async_profile.rst diff --git a/doc/source/user_guide/profile_attributes.rst b/doc/source/user_guide/profile_attributes.rst new file mode 100644 index 0000000..eb5146a --- /dev/null +++ b/doc/source/user_guide/profile_attributes.rst @@ -0,0 +1,12 @@ +.. _profile_attributes: + +************************* +``ProfileAttributes`` +************************* + +This class defines attributes to manage and configure the behavior of +the AI profile. The ``ProfileAttributes`` objects are created +by ``select_ai.ProfileAttributes()``. + +.. autoclass:: select_ai.ProfileAttributes + :members: diff --git a/doc/source/user_guide/provider.rst b/doc/source/user_guide/provider.rst new file mode 100644 index 0000000..1e57793 --- /dev/null +++ b/doc/source/user_guide/provider.rst @@ -0,0 +1,129 @@ +.. _provider: + +An AI Provider in Select AI refers to the service provider of the +LLM, transformer or both for processing and generating responses to natural +language prompts. These providers offer models that can interpret and convert +natural language for the use cases highlighted under the LLM concept. + +See `Select your AI Provider `__ +for the supported providers + +.. latex:clearpage:: + +********************** +``Provider`` +********************** + +.. autoclass:: select_ai.Provider + :members: + +.. latex:clearpage:: + +********************************* +``AnthropicProvider`` +********************************* +.. autoclass:: select_ai.AnthropicProvider + :members: + +.. latex:clearpage:: + +***************************** +``AzureProvider`` +***************************** +.. autoclass:: select_ai.AzureProvider + :members: + +.. latex:clearpage:: + +***************************** +``AWSProvider`` +***************************** +.. autoclass:: select_ai.AWSProvider + :members: + +.. latex:clearpage:: + +****************************** +``CohereProvider`` +****************************** +.. autoclass:: select_ai.CohereProvider + :members: + +.. latex:clearpage:: + +***************************** +``OpenAIProvider`` +***************************** +.. autoclass:: select_ai.OpenAIProvider + :members: + +.. latex:clearpage:: + +****************************** +``OCIGenAIProvider`` +****************************** +.. autoclass:: select_ai.OCIGenAIProvider + :members: + +.. latex:clearpage:: + +****************************** +``GoogleProvider`` +****************************** +.. autoclass:: select_ai.GoogleProvider + :members: + +.. latex:clearpage:: + +*********************************** +``HuggingFaceProvider`` +*********************************** +.. autoclass:: select_ai.HuggingFaceProvider + :members: + +.. latex:clearpage:: + +************************** +Enable AI service provider +************************** + +.. note:: + + All sample scripts in this documentation read Oracle database connection + details from the environment. Create a dotenv file ``.env``, export the + the following environment variables and source it before running the + scripts. + + .. code-block:: sh + + export SELECT_AI_ADMIN_USER= + export SELECT_AI_ADMIN_PASSWORD= + export SELECT_AI_USER= + export SELECT_AI_PASSWORD= + export SELECT_AI_DB_CONNECT_STRING= + export TNS_ADMIN= + +This method grants execute privilege on the packages +``DBMS_CLOUD``, ``DBMS_CLOUD_AI`` and ``DBMS_CLOUD_PIPELINE``. It +also enables the user to invoke the AI(LLM) endpoint hosted at a +certain domain + +.. literalinclude:: ../../../samples/enable_ai_provider.py + :language: python + +output:: + + Enabled AI provider for user: + +.. latex:clearpage:: + +*************************** +Disable AI service provider +*************************** + +.. literalinclude:: ../../../samples/disable_ai_provider.py + :language: python + +output:: + + Disabled AI provider for user: diff --git a/doc/source/user_guide/synthetic_data.rst b/doc/source/user_guide/synthetic_data.rst new file mode 100644 index 0000000..bec0601 --- /dev/null +++ b/doc/source/user_guide/synthetic_data.rst @@ -0,0 +1,71 @@ +.. _synthetic_data: + +*************************** +``SyntheticDataAttributes`` +*************************** + +.. autoclass:: select_ai.SyntheticDataAttributes + :members: + +.. latex:clearpage:: + +*********************** +``SyntheticDataParams`` +*********************** + +.. autoclass:: select_ai.SyntheticDataParams + :members: + +Also, check the `generate_synthetic_data PL/SQL API `__ +for attribute details + +.. latex:clearpage:: + +**************************** +Single table synthetic data +**************************** + +The below example shows single table synthetic data generation + +.. literalinclude:: ../../../samples/profile_gen_single_table_synthetic_data.py + :language: python + +output:: + + SQL> select count(*) from movie; + + COUNT(*) + ---------- + 100 + +.. latex:clearpage:: + +**************************** +Multi table synthetic data +**************************** + +The below example shows multitable synthetic data generation + +.. literalinclude:: ../../../samples/profile_gen_multi_table_synthetic_data.py + :language: python + + +output:: + + SQL> select count(*) from actor; + + COUNT(*) + ---------- + 40 + + SQL> select count(*) from director; + + COUNT(*) + ---------- + 13 + + SQL> select count(*) from movie; + + COUNT(*) + ---------- + 300 diff --git a/doc/source/user_guide/vector_index.rst b/doc/source/user_guide/vector_index.rst new file mode 100644 index 0000000..d29b0a5 --- /dev/null +++ b/doc/source/user_guide/vector_index.rst @@ -0,0 +1,176 @@ +.. _vector_index: + +``VectorIndex`` supports Retrieval Augmented Generation (RAG). +For e.g., you can convert text into vector embeddings and store them in a +vector store. Select AI will augment the natural language prompt by retrieving +content from the vector store using semantic similarity search. + +**************************** +``VectorIndex`` Object Model +**************************** + +.. _vectorindexfig: +.. figure:: /image/vector_index.png + :alt: Select AI Vector Index + +.. latex:clearpage:: + +************************* +``VectorIndexAttributes`` +************************* + +A ``VectorIndexAttributes`` object can be created with +``select_ai.VectorIndexAttributes()``. Also check +`vector index attributes `__ + + +.. autoclass:: select_ai.VectorIndexAttributes + :members: + + +``OracleVectorIndexAttributes`` ++++++++++++++++++++++++++++++++ + +.. autoclass:: select_ai.OracleVectorIndexAttributes + :members: + +.. latex:clearpage:: + +******************** +``VectorIndex`` API +******************** + + +A ``VectorIndex`` object can be created with ``select_ai.VectorIndex()`` + +.. autoclass:: select_ai.VectorIndex + :members: + + +Check the examples below to understand how to create vector indexes + +.. latex:clearpage:: + +Create vector index ++++++++++++++++++++ + +In the following example, vector database provider is Oracle and +objects (to create embedding for) reside in OCI's object store + +.. literalinclude:: ../../../samples/vector_index_create.py + :language: python + +output:: + + Created vector index: test_vector_index + +.. latex:clearpage:: + +List vector index ++++++++++++++++++ + +.. literalinclude:: ../../../samples/vector_index_list.py + :language: python + +output:: + + Vector index TEST_VECTOR_INDEX + Vector index profile Profile(profile_name=oci_vector_ai_profile, attributes=ProfileAttributes(annotations=None, case_sensitive_values=None, comments=None, constraints=None, conversation=None, credential_name='my_oci_ai_profile_key', enable_sources=None, enable_source_offsets=None, enforce_object_list=None, max_tokens=1024, object_list=None, object_list_mode=None, provider=OCIGenAIProvider(embedding_model=None, model=None, provider_name='oci', provider_endpoint=None, region='us-chicago-1', oci_apiformat='GENERIC', oci_compartment_id=None, oci_endpoint_id=None, oci_runtimetype=None), seed=None, stop_tokens=None, streaming=None, temperature=None, vector_index_name='test_vector_index'), description=None) + +.. latex:clearpage:: + +RAG using vector index +++++++++++++++++++++++ + +.. literalinclude:: ../../../samples/vector_index_rag.py + :language: python + +output:: + + The conda environments in your object store are: + 1. fccenv + 2. myrenv + 3. fully-loaded-mlenv + 4. graphenv + + These environments are listed in the provided data as separate JSON documents, each containing information about a specific conda environment. + + Sources: + - fccenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/fccenv-manifest.json) + - myrenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/myrenv-manifest.json) + - fully-loaded-mlenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/fully-loaded-mlenv-manifest.json) + - graphenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/graphenv-manifest.json) + +.. latex:clearpage:: + +Delete vector index ++++++++++++++++++++ + +.. literalinclude:: ../../../samples/vector_index_delete.py + :language: python + +output:: + + Deleted vector index: test_vector_index + +.. latex:clearpage:: + +************************ +``AsyncVectorIndex`` API +************************ + +A ``AsyncVectorIndex`` object can be created with ``select_ai.AsyncVectorIndex()`` + +.. autoclass:: select_ai.AsyncVectorIndex + :members: + +.. latex:clearpage:: + +Async create vector index ++++++++++++++++++++++++++ + +.. literalinclude:: ../../../samples/async/vector_index_create.py + :language: python + +output:: + + created vector index: test_vector_index + + +.. latex:clearpage:: + +Async list vector index +++++++++++++++++++++++++ + +.. literalinclude:: ../../../samples/async/vector_index_list.py + :language: python + +output:: + + Vector index TEST_VECTOR_INDEX + Vector index profile AsyncProfile(profile_name=oci_vector_ai_profile, attributes=ProfileAttributes(annotations=None, case_sensitive_values=None, comments=None, constraints=None, conversation=None, credential_name='my_oci_ai_profile_key', enable_sources=None, enable_source_offsets=None, enforce_object_list=None, max_tokens=1024, object_list=None, object_list_mode=None, provider=OCIGenAIProvider(embedding_model=None, model=None, provider_name='oci', provider_endpoint=None, region='us-chicago-1', oci_apiformat='GENERIC', oci_compartment_id=None, oci_endpoint_id=None, oci_runtimetype=None), seed=None, stop_tokens=None, streaming=None, temperature=None, vector_index_name='test_vector_index'), description=None) + + +.. latex:clearpage:: + +Async RAG using vector index +++++++++++++++++++++++++++++ + +.. literalinclude:: ../../../samples/async/vector_index_rag.py + :language: python + +output:: + + The conda environments in your object store are: + 1. fccenv + 2. myrenv + 3. fully-loaded-mlenv + 4. graphenv + + These environments are listed in the provided data as separate JSON documents, each containing information about a specific conda environment. + + Sources: + - fccenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/fccenv-manifest.json) + - myrenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/myrenv-manifest.json) + - fully-loaded-mlenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/fully-loaded-mlenv-manifest.json) + - graphenv-manifest.json (https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/graphenv-manifest.json) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c692e02 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools >= 77.0.3", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "select_ai" +dynamic = ["version"] +description = "Python API for Select AI" +requires-python = ">=3.9" +authors = [ + {name="Abhishek Singh", email="abhishek.o.singh@oracle.com"} +] +maintainers =[ + {name="Abhishek Singh", email="abhishek.o.singh@oracle.com"} +] +keywords = [ + "oracle", + "select-ai", + "adbs", + "autonomous database serverless" +] +license = " UPL-1.0" +license-files = ["LICENSE.txt"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Database" +] +dependencies = [ + "oracledb", + "pandas==2.2.3" +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.dynamic] +version = { attr = "select_ai.version.__version__" } + +[tool.black] +line-length = 79 +target-version = ["py39", "py310", "py311", "py312", "py313"] +required-version = 24 + +[tool.ruff] +line-length = 79 +target-version = "py39" +per-file-ignores = { "__init__.py" = ["F401"] } diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..af0844e --- /dev/null +++ b/samples/README.md @@ -0,0 +1,57 @@ +# select_ai samples + +This directory contains samples for python-select-ai. To run the scripts, +define and export the following environment variables + +```dotenv +export SELECT_AI_ADMIN_USER= +export SELECT_AI_ADMIN_PASSWORD= +export SELECT_AI_USER= +export SELECT_AI_PASSWORD= +export SELECT_AI_DB_CONNECT_STRING= +export TNS_ADMIN= +``` + +> Note: In production, do not save secrets in environment variables + +> `SELECT_AI_ADMIN_USER` and `SELECT_AI_ADMIN_PASSWORD` are needed only to +> grant privileges to regular user. They are used in 2 sample scripts +> `enable_ai_provider.py` and `disable_ai_provider.py` + + +`SELECT_AI_DB_CONNECT_STRING` can be in any one of the following formats + +- TNS alias + + ```bash + export SELECT_AI_DB_CONNECT_STRING=db2025adb_medium + ``` + + Ensure there is an entry in `$TNS_ADMIN/tnsnames.ora` mapping to the connect descriptor + + ```bash + >> tnsnames.ora + + db2025adb_medium = (description= (retry_count=20)(retry_delay=3) + (address=(protocol=tcps)(port=1521)(host=adb..oraclecloud.com)) + (connect_data=(service_name=db2025adb_medium.adb.oraclecloud.com)) + (security=(ssl_server_dn_match=yes))) + ``` + + + +- Complete connect string + + ```bash + export SELECT_AI_DB_CONNECT_STRING="(description= (retry_count=20)(retry_delay=3) + (address=(protocol=tcps)(port=1521)(host=adb..oraclecloud.com)) + (connect_data=(service_name=db2025adb_medium.adb.oraclecloud.com)) + (security=(ssl_server_dn_match=yes)))" + + ``` + +- Simplified connect string + + ```bash + export SELECT_AI_DB_CONNECT_STRING="tcps://adb..oraclecloud.com:1521/db2025adb_medium.adb.oraclecloud.com?retry_count=2&retry_delay=3" + ``` diff --git a/samples/async/conversation_chat_session.py b/samples/async/conversation_chat_session.py new file mode 100644 index 0000000..765e748 --- /dev/null +++ b/samples/async/conversation_chat_session.py @@ -0,0 +1,49 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- +# async/conversation_chat_session.py +# +# Demonstrates context aware conversation using AI Profile +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile" + ) + conversation_attributes = select_ai.ConversationAttributes( + title="History of Science", + description="LLM's understanding of history of science", + ) + async_conversation = select_ai.AsyncConversation( + attributes=conversation_attributes + ) + + async with async_profile.chat_session( + conversation=async_conversation, delete=True + ) as async_session: + response = await async_session.chat( + prompt="What is importance of history of science ?" + ) + print(response) + response = await async_session.chat( + prompt="Elaborate more on 'Learning from past mistakes'" + ) + print(response) + + +asyncio.run(main()) diff --git a/samples/async/conversations_list.py b/samples/async/conversations_list.py new file mode 100644 index 0000000..a15d77e --- /dev/null +++ b/samples/async/conversations_list.py @@ -0,0 +1,31 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/conversations_list.py +# +# List all conversations saved in the database +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async for conversation in select_ai.AsyncConversation().list(): + print(conversation.conversation_id) + print(conversation.attributes) + + +asyncio.run(main()) diff --git a/samples/async/profile_chat.py b/samples/async/profile_chat.py new file mode 100644 index 0000000..db9dbcf --- /dev/null +++ b/samples/async/profile_chat.py @@ -0,0 +1,41 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_chat.py +# +# Chat using an AI Profile +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile" + ) + + # Asynchronously send multiple chat prompts + chat_tasks = [ + async_profile.chat(prompt="What is OCI ?"), + async_profile.chat(prompt="What is OML4PY?"), + async_profile.chat(prompt="What is Autonomous Database ?"), + ] + for chat_task in asyncio.as_completed(chat_tasks): + result = await chat_task + print(result) + + +asyncio.run(main()) diff --git a/samples/async/profile_create.py b/samples/async/profile_create.py new file mode 100644 index 0000000..eb9e903 --- /dev/null +++ b/samples/async/profile_create.py @@ -0,0 +1,50 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_create.py +# +# Create an OCI Gen AI profile +# ----------------------------------------------------------------------------- + +import asyncio +import os +from pprint import pformat + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +# This example shows how to asynchronously generate SQLs nad run SQLs +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + provider = select_ai.OCIGenAIProvider( + region="us-chicago-1", oci_apiformat="GENERIC" + ) + profile_attributes = select_ai.ProfileAttributes( + credential_name="my_oci_ai_profile_key", + object_list=[{"owner": "SH"}], + provider=provider, + ) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile", + attributes=profile_attributes, + description="MY OCI AI Profile", + replace=True, + ) + print("Created async profile ", async_profile.profile_name) + profile_attributes = await async_profile.get_attributes() + print( + "Profile attributes: ", + pformat(profile_attributes.dict(exclude_null=False)), + ) + + +asyncio.run(main()) diff --git a/samples/async/profile_explain_sql.py b/samples/async/profile_explain_sql.py new file mode 100644 index 0000000..17bd92d --- /dev/null +++ b/samples/async/profile_explain_sql.py @@ -0,0 +1,32 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_explain_sql.py +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +# This example shows how to asynchronously ask the LLM to explain SQL +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile", + ) + response = await async_profile.explain_sql("How many promotions") + print(response) + + +asyncio.run(main()) diff --git a/samples/async/profile_pipeline.py b/samples/async/profile_pipeline.py new file mode 100644 index 0000000..616447a --- /dev/null +++ b/samples/async/profile_pipeline.py @@ -0,0 +1,54 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_pipeline.py +# +# Demonstrates sending multiple prompts using a single Database round-trip +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile" + ) + prompt_specifications = [ + ("What is Oracle Autonomous Database?", select_ai.Action.CHAT), + ("Generate SQL to list all customers?", select_ai.Action.SHOWSQL), + ( + "Explain the query: SELECT * FROM sh.products", + select_ai.Action.EXPLAINSQL, + ), + ("Explain the query: SELECT * FROM sh.products", "INVALID ACTION"), + ] + + # 1. Multiple prompts are sent in a single roundtrip to the Database + # 2. Results are returned as soon as Database has executed all prompts + # 3. Application doesn't have to wait on one response before sending + # the next prompts + # 4. Fewer round trips and database is kept busy + # 5. Efficient network usage + results = await async_profile.run_pipeline( + prompt_specifications, continue_on_error=True + ) + for i, result in enumerate(results): + print( + f"Result {i} for prompt '{prompt_specifications[i][0]}' is: {result}" + ) + + +asyncio.run(main()) diff --git a/samples/async/profile_run_sql.py b/samples/async/profile_run_sql.py new file mode 100644 index 0000000..837c234 --- /dev/null +++ b/samples/async/profile_run_sql.py @@ -0,0 +1,35 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_run_sql.py +# +# Return a pandas.Dataframe built using the resultset of the generated SQL +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +# This example shows how to asynchronously run sql +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile", + ) + # run_sql returns a pandas df + df = await async_profile.run_sql("How many promotions") + print(df) + + +asyncio.run(main()) diff --git a/samples/async/profile_show_sql.py b/samples/async/profile_show_sql.py new file mode 100644 index 0000000..93a3f22 --- /dev/null +++ b/samples/async/profile_show_sql.py @@ -0,0 +1,33 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_show_sql.py +# +# Show the generated SQL without executing it +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile", + ) + response = await async_profile.show_sql("How many promotions") + print(response) + + +asyncio.run(main()) diff --git a/samples/async/profile_sql_concurrent_tasks.py b/samples/async/profile_sql_concurrent_tasks.py new file mode 100644 index 0000000..11f2b3a --- /dev/null +++ b/samples/async/profile_sql_concurrent_tasks.py @@ -0,0 +1,42 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_sql_concurrent_tasks.py +# +# Demonstrates sending multiple prompts concurrently using asyncio +# ----------------------------------------------------------------------------- + + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile", + ) + sql_tasks = [ + async_profile.show_sql(prompt="How many customers?"), + async_profile.run_sql(prompt="How many promotions"), + async_profile.explain_sql(prompt="How many promotions"), + ] + + # Collect results from multiple asynchronous tasks + for sql_task in asyncio.as_completed(sql_tasks): + result = await sql_task + print(result) + + +asyncio.run(main()) diff --git a/samples/async/profiles_list.py b/samples/async/profiles_list.py new file mode 100644 index 0000000..86d1043 --- /dev/null +++ b/samples/async/profiles_list.py @@ -0,0 +1,35 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/profile_list.py +# +# List all the profile names matching a certain pattern +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile() + # matches the start of string + async for fetched_profile in async_profile.list( + profile_name_pattern="^oci" + ): + p = await fetched_profile + print(p.profile_name) + + +asyncio.run(main()) diff --git a/samples/async/vector_index_create.py b/samples/async/vector_index_create.py new file mode 100644 index 0000000..a2159b8 --- /dev/null +++ b/samples/async/vector_index_create.py @@ -0,0 +1,59 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/vector_index_create.py +# +# Create a vector index for Retrieval Augmented Generation (RAG) +# ----------------------------------------------------------------------------- + + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + + provider = select_ai.OCIGenAIProvider( + region="us-chicago-1", + oci_apiformat="GENERIC", + embedding_model="cohere.embed-english-v3.0", + ) + profile_attributes = select_ai.ProfileAttributes( + credential_name="my_oci_ai_profile_key", + provider=provider, + ) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_vector_ai_profile", + attributes=profile_attributes, + description="MY OCI AI Profile", + replace=True, + ) + + vector_index_attributes = select_ai.OracleVectorIndexAttributes( + location="https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph", + object_storage_credential_name="my_oci_ai_profile_key", + ) + + async_vector_index = select_ai.AsyncVectorIndex( + index_name="test_vector_index", + attributes=vector_index_attributes, + description="Vector index for conda environments", + profile=async_profile, + ) + await async_vector_index.create(replace=True) + print("Created vector index: test_vector_index") + + +asyncio.run(main()) diff --git a/samples/async/vector_index_delete.py b/samples/async/vector_index_delete.py new file mode 100644 index 0000000..8a7e299 --- /dev/null +++ b/samples/async/vector_index_delete.py @@ -0,0 +1,29 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/vector_index_delete.py +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + vector_index = select_ai.AsyncVectorIndex(index_name="test_vector_index") + await vector_index.delete() + print("Vector index deleted") + + +asyncio.run(main()) diff --git a/samples/async/vector_index_list.py b/samples/async/vector_index_list.py new file mode 100644 index 0000000..51e0526 --- /dev/null +++ b/samples/async/vector_index_list.py @@ -0,0 +1,33 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/vector_index_list.py +# +# List all the vector indexes and associated profile where the index name +# matches a certain pattern +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + vector_index = select_ai.AsyncVectorIndex() + async for index in vector_index.list(index_name_pattern="^test"): + print("Vector index", index.index_name) + print("Vector index profile", index.profile) + + +asyncio.run(main()) diff --git a/samples/async/vector_index_rag.py b/samples/async/vector_index_rag.py new file mode 100644 index 0000000..8589f50 --- /dev/null +++ b/samples/async/vector_index_rag.py @@ -0,0 +1,36 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/vector_index_rag.py +# +# Demonstrates Retrieval Augmented Generation (RAG) using ai_profile.narrate() +# ----------------------------------------------------------------------------- + + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_vector_ai_profile" + ) + r = await async_profile.narrate( + "list the conda environments in my object store" + ) + print(r) + + +asyncio.run(main()) diff --git a/samples/conversation_chat_session.py b/samples/conversation_chat_session.py new file mode 100644 index 0000000..df7f219 --- /dev/null +++ b/samples/conversation_chat_session.py @@ -0,0 +1,40 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# conversation_chat_session.py +# +# Demonstrates context aware conversation using AI Profile +# ----------------------------------------------------------------------------- +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +conversation_attributes = select_ai.ConversationAttributes( + title="History of Science", + description="LLM's understanding of history of science", +) +conversation = select_ai.Conversation(attributes=conversation_attributes) +with profile.chat_session(conversation=conversation, delete=True) as session: + print( + "Conversation ID for this session is:", + conversation.conversation_id, + ) + response = session.chat( + prompt="What is importance of history of science ?" + ) + print(response) + response = session.chat( + prompt="Elaborate more on 'Learning from past mistakes'" + ) + print(response) diff --git a/samples/conversation_create.py b/samples/conversation_create.py new file mode 100644 index 0000000..a231884 --- /dev/null +++ b/samples/conversation_create.py @@ -0,0 +1,30 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# conversation_create.py +# +# Create a new conversation given a title and description. The created +# conversation can be used in profile.chat_session() +# ----------------------------------------------------------------------------- +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +conversation_attributes = select_ai.ConversationAttributes( + title="History of Science", + description="LLM's understanding of history of science", +) +conversation = select_ai.Conversation(attributes=conversation_attributes) +conversation_id = conversation.create() + +print("Created conversation with conversation id: ", conversation_id) diff --git a/samples/conversation_delete.py b/samples/conversation_delete.py new file mode 100644 index 0000000..c708300 --- /dev/null +++ b/samples/conversation_delete.py @@ -0,0 +1,30 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# conversation_delete.py +# +# Delete conversation given a conversation id +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +conversation = select_ai.Conversation( + conversation_id="37DDC22E-11C8-3D49-E063-D81A000A85FE" +) +conversation.delete(force=True) +print( + "Deleted conversation with conversation id: ", + conversation.conversation_id, +) diff --git a/samples/conversations_list.py b/samples/conversations_list.py new file mode 100644 index 0000000..516c9b0 --- /dev/null +++ b/samples/conversations_list.py @@ -0,0 +1,25 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# conversations_list.py +# +# List all conversations saved in the database +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +for conversation in select_ai.Conversation().list(): + print(conversation.conversation_id) + print(conversation.attributes) diff --git a/samples/create_ai_credential.py b/samples/create_ai_credential.py new file mode 100644 index 0000000..fd38be4 --- /dev/null +++ b/samples/create_ai_credential.py @@ -0,0 +1,37 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# create_ai_credential.py +# +# Create a Database credential storing OCI Gen AI's credentials +# ----------------------------------------------------------------------------- +import os + +import oci +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) + +# Default config file and profile +default_config = oci.config.from_file() +oci.config.validate_config(default_config) +with open(default_config["key_file"]) as fp: + key_contents = fp.read() +credential = { + "credential_name": "my_oci_ai_profile_key", + "user_ocid": default_config["user"], + "tenancy_ocid": default_config["tenancy"], + "private_key": key_contents, + "fingerprint": default_config["fingerprint"], +} +select_ai.create_credential(credential=credential, replace=True) +print("Created credential: ", credential["credential_name"]) diff --git a/samples/disable_ai_provider.py b/samples/disable_ai_provider.py new file mode 100644 index 0000000..66d3748 --- /dev/null +++ b/samples/disable_ai_provider.py @@ -0,0 +1,27 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# disable_ai_provider.py +# +# Revokes privileges from the database user and removes ACL to invoke the AI +# Provider endpoint +# ----------------------------------------------------------------------------- +import os + +import select_ai + +admin_user = os.getenv("SELECT_AI_ADMIN_USER") +password = os.getenv("SELECT_AI_ADMIN_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +select_ai_user = os.getenv("SELECT_AI_USER") + +select_ai.connect(user=admin_user, password=password, dsn=dsn) +select_ai.disable_provider( + users=select_ai_user, provider_endpoint="*.openai.azure.com" +) +print("Disabled AI provider for user: ", select_ai_user) diff --git a/samples/enable_ai_provider.py b/samples/enable_ai_provider.py new file mode 100644 index 0000000..6391062 --- /dev/null +++ b/samples/enable_ai_provider.py @@ -0,0 +1,28 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# enable_ai_provider.py +# +# Grants privileges to the database user and add ACL to invoke the AI Provider +# endpoint +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +admin_user = os.getenv("SELECT_AI_ADMIN_USER") +password = os.getenv("SELECT_AI_ADMIN_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +select_ai_user = os.getenv("SELECT_AI_USER") + +select_ai.connect(user=admin_user, password=password, dsn=dsn) +select_ai.enable_provider( + users=select_ai_user, provider_endpoint="*.openai.azure.com" +) +print("Enabled AI provider for user: ", select_ai_user) diff --git a/samples/profile_chat.py b/samples/profile_chat.py new file mode 100644 index 0000000..b7ea448 --- /dev/null +++ b/samples/profile_chat.py @@ -0,0 +1,25 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_chat.py +# +# Chat using an AI Profile +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +response = profile.chat(prompt="What is OCI ?") +print(response) diff --git a/samples/profile_create.py b/samples/profile_create.py new file mode 100644 index 0000000..06aaa4b --- /dev/null +++ b/samples/profile_create.py @@ -0,0 +1,43 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_create.py +# +# Create an OCI Gen AI profile +# ----------------------------------------------------------------------------- + +import os +from pprint import pformat + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +provider = select_ai.OCIGenAIProvider( + region="us-chicago-1", oci_apiformat="GENERIC" +) +profile_attributes = select_ai.ProfileAttributes( + credential_name="my_oci_ai_profile_key", + object_list=[{"owner": "SH"}], + provider=provider, +) +profile = select_ai.Profile( + profile_name="oci_ai_profile", + attributes=profile_attributes, + description="MY OCI AI Profile", + replace=True, +) +print("Created profile ", profile.profile_name) +profile_attributes = profile.get_attributes() +print( + "Profile attributes are: ", + pformat(profile_attributes.dict(exclude_null=False)), +) diff --git a/samples/profile_delete.py b/samples/profile_delete.py new file mode 100644 index 0000000..3cee3b4 --- /dev/null +++ b/samples/profile_delete.py @@ -0,0 +1,22 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_delete.py +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +profile.delete() diff --git a/samples/profile_explain_sql.py b/samples/profile_explain_sql.py new file mode 100644 index 0000000..7ab0b00 --- /dev/null +++ b/samples/profile_explain_sql.py @@ -0,0 +1,28 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_explain_sql.py +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile( + profile_name="oci_ai_profile", +) +print(profile.description) +explanation = profile.explain_sql( + prompt="How many promotions are there in the sh database?" +) +print(explanation) diff --git a/samples/profile_gen_multi_table_synthetic_data.py b/samples/profile_gen_multi_table_synthetic_data.py new file mode 100644 index 0000000..e4ceeeb --- /dev/null +++ b/samples/profile_gen_multi_table_synthetic_data.py @@ -0,0 +1,42 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_gen_multi_table_synthetic_data.py +# +# Generate synthetic data for multiple tables in a single request +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +synthetic_data_params = select_ai.SyntheticDataParams( + sample_rows=100, table_statistics=True, priority="HIGH" +) +object_list = [ + { + "owner": user, + "name": "MOVIE", + "record_count": 100, + "user_prompt": "the release date for the movies should be in 2019", + }, + {"owner": user, "name": "ACTOR", "record_count": 10}, + {"owner": user, "name": "DIRECTOR", "record_count": 5}, +] +synthetic_data_attributes = select_ai.SyntheticDataAttributes( + object_list=object_list, params=synthetic_data_params +) +profile.generate_synthetic_data( + synthetic_data_attributes=synthetic_data_attributes +) diff --git a/samples/profile_gen_single_table_synthetic_data.py b/samples/profile_gen_single_table_synthetic_data.py new file mode 100644 index 0000000..09143ff --- /dev/null +++ b/samples/profile_gen_single_table_synthetic_data.py @@ -0,0 +1,35 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_gen_single_table_synthetic_data.py +# +# Generate synthetic data for a single table +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +synthetic_data_params = select_ai.SyntheticDataParams( + sample_rows=100, table_statistics=True, priority="HIGH" +) +synthetic_data_attributes = select_ai.SyntheticDataAttributes( + object_name="MOVIE", + user_prompt="the release date for the movies should be in 2019", + params=synthetic_data_params, + record_count=100, +) +profile.generate_synthetic_data( + synthetic_data_attributes=synthetic_data_attributes +) diff --git a/samples/profile_narrate.py b/samples/profile_narrate.py new file mode 100644 index 0000000..fb62f81 --- /dev/null +++ b/samples/profile_narrate.py @@ -0,0 +1,29 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_narrate.py +# +# Narrate the description of the SQL resultset +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile( + profile_name="oci_ai_profile", +) +narration = profile.narrate( + prompt="How many promotions are there in the sh database?" +) +print(narration) diff --git a/samples/profile_run_sql.py b/samples/profile_run_sql.py new file mode 100644 index 0000000..fbfba95 --- /dev/null +++ b/samples/profile_run_sql.py @@ -0,0 +1,28 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_run_sql.py +# +# Return a pandas.Dataframe built using the resultset of the generated SQL +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +df = profile.run_sql( + prompt="How many promotions are there in the sh database?" +) +print(df.columns) +print(df) diff --git a/samples/profile_show_sql.py b/samples/profile_show_sql.py new file mode 100644 index 0000000..57af084 --- /dev/null +++ b/samples/profile_show_sql.py @@ -0,0 +1,27 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_show_sql.py +# +# Show the generated SQL without executing it +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +sql = profile.show_sql( + prompt="How many promotions are there in the sh database?" +) +print(sql) diff --git a/samples/profiles_list.py b/samples/profiles_list.py new file mode 100644 index 0000000..5811ef2 --- /dev/null +++ b/samples/profiles_list.py @@ -0,0 +1,27 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# profile_list.py +# +# List all the profile names matching a certain pattern +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile() + +# matches all the profiles +for fetched_profile in profile.list(): + print(fetched_profile.profile_name) diff --git a/samples/vector_index_create.py b/samples/vector_index_create.py new file mode 100644 index 0000000..c29c21b --- /dev/null +++ b/samples/vector_index_create.py @@ -0,0 +1,60 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# vector_index_create.py +# +# Create a vector index for Retrieval Augmented Generation (RAG) +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +select_ai.connect(user=user, password=password, dsn=dsn) +# Configure an AI provider with an embedding model +# of your choice +provider = select_ai.OCIGenAIProvider( + region="us-chicago-1", + oci_apiformat="GENERIC", + embedding_model="cohere.embed-english-v3.0", +) + +# Create an AI profile to use the Vector index with +profile_attributes = select_ai.ProfileAttributes( + credential_name="my_oci_ai_profile_key", + provider=provider, +) +profile = select_ai.Profile( + profile_name="oci_vector_ai_profile", + attributes=profile_attributes, + description="MY OCI AI Profile", + replace=True, +) + +# Specify objects to create an embedding for. In this example, +# the objects reside in ObjectStore and the vector database is +# Oracle +vector_index_attributes = select_ai.OracleVectorIndexAttributes( + location="https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph", + object_storage_credential_name="my_oci_ai_profile_key", +) + +# Create a Vector index object +vector_index = select_ai.VectorIndex( + index_name="test_vector_index", + attributes=vector_index_attributes, + description="Test vector index", + profile=profile, +) +vector_index.create(replace=True) +print("Created vector index: test_vector_index") diff --git a/samples/vector_index_delete.py b/samples/vector_index_delete.py new file mode 100644 index 0000000..532cdad --- /dev/null +++ b/samples/vector_index_delete.py @@ -0,0 +1,23 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# vector_index_delete.py +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +vector_index = select_ai.VectorIndex(index_name="test_vector_index") +vector_index.delete(force=True) +print("Deleted vector index: test_vector_index") diff --git a/samples/vector_index_list.py b/samples/vector_index_list.py new file mode 100644 index 0000000..6a68b83 --- /dev/null +++ b/samples/vector_index_list.py @@ -0,0 +1,27 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# vector_index_list.py +# +# List all the vector indexes and associated profile where the index name +# matches a certain pattern +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +vector_index = select_ai.VectorIndex() +for index in vector_index.list(index_name_pattern="^test"): + print("Vector index", index.index_name) + print("Vector index profile", index.profile) diff --git a/samples/vector_index_rag.py b/samples/vector_index_rag.py new file mode 100644 index 0000000..5885592 --- /dev/null +++ b/samples/vector_index_rag.py @@ -0,0 +1,25 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# vector_index_rag.py +# +# Demonstrates Retrieval Augmented Generation (RAG) using ai_profile.narrate() +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_vector_ai_profile") +r = profile.narrate("list the conda environments in my object store") +print(r) diff --git a/src/select_ai/__init__.py b/src/select_ai/__init__.py new file mode 100644 index 0000000..cc79842 --- /dev/null +++ b/src/select_ai/__init__.py @@ -0,0 +1,54 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +from .action import Action +from .admin import ( + create_credential, + disable_provider, + enable_provider, +) +from .async_profile import AsyncProfile +from .base_profile import BaseProfile, ProfileAttributes +from .conversation import ( + AsyncConversation, + Conversation, + ConversationAttributes, +) +from .db import ( + async_connect, + async_cursor, + async_disconnect, + async_is_connected, + connect, + cursor, + disconnect, + is_connected, +) +from .profile import Profile +from .provider import ( + AnthropicProvider, + AWSProvider, + AzureProvider, + CohereProvider, + GoogleProvider, + HuggingFaceProvider, + OCIGenAIProvider, + OpenAIProvider, + Provider, +) +from .synthetic_data import ( + SyntheticDataAttributes, + SyntheticDataParams, +) +from .vector_index import ( + AsyncVectorIndex, + OracleVectorIndexAttributes, + VectorDistanceMetric, + VectorIndex, + VectorIndexAttributes, +) +from .version import __version__ as __version__ diff --git a/src/select_ai/_abc.py b/src/select_ai/_abc.py new file mode 100644 index 0000000..b3a875b --- /dev/null +++ b/src/select_ai/_abc.py @@ -0,0 +1,77 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import json +import typing +from abc import ABC +from dataclasses import dataclass, fields +from typing import Any, List, Mapping + +__all__ = ["SelectAIDataClass"] + + +def _bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if value.lower() in ("yes", "true", "t", "y", "1"): + return True + elif value.lower() in ("no", "false", "f", "n", "0"): + return False + else: + raise ValueError(f"Invalid boolean value: {value}") + + +@dataclass +class SelectAIDataClass(ABC): + """SelectAIDataClass is an abstract container for all data + models defined in the select_ai Python module + """ + + def __getitem__(self, item): + return getattr(self, item) + + def __setitem__(self, key, value): + setattr(self, key, value) + + @classmethod + def keys(cls): + return set([field.name for field in fields(cls)]) + + def dict(self, exclude_null=True): + attributes = {} + for k, v in self.__dict__.items(): + if v is not None or not exclude_null: + attributes[k] = v + return attributes + + def json(self, exclude_null=True): + return json.dumps(self.dict(exclude_null=exclude_null)) + + def __post_init__(self): + for field in fields(self): + value = getattr(self, field.name) + if value is not None: + if field.type is typing.Optional[int]: + setattr(self, field.name, int(value)) + elif field.type is typing.Optional[str]: + setattr(self, field.name, str(value)) + elif field.type is typing.Optional[bool]: + setattr(self, field.name, _bool(value)) + elif field.type is typing.Optional[float]: + setattr(self, field.name, float(value)) + elif field.type is typing.Optional[Mapping] and isinstance( + value, (str, bytes, bytearray) + ): + setattr(self, field.name, json.loads(value)) + elif field.type is typing.Optional[ + List[typing.Mapping] + ] and isinstance(value, (str, bytes, bytearray)): + setattr(self, field.name, json.loads(value)) + else: + setattr(self, field.name, value) diff --git a/src/select_ai/_enums.py b/src/select_ai/_enums.py new file mode 100644 index 0000000..8007185 --- /dev/null +++ b/src/select_ai/_enums.py @@ -0,0 +1,14 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import enum + + +class StrEnum(str, enum.Enum): + + def __str__(self): + return self.value diff --git a/src/select_ai/action.py b/src/select_ai/action.py new file mode 100644 index 0000000..fe1991b --- /dev/null +++ b/src/select_ai/action.py @@ -0,0 +1,21 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +from select_ai._enums import StrEnum + +__all__ = ["Action"] + + +class Action(StrEnum): + """Supported Select AI actions""" + + RUNSQL = "runsql" + SHOWSQL = "showsql" + EXPLAINSQL = "explainsql" + NARRATE = "narrate" + CHAT = "chat" + SHOWPROMPT = "showprompt" diff --git a/src/select_ai/admin.py b/src/select_ai/admin.py new file mode 100644 index 0000000..0d195fb --- /dev/null +++ b/src/select_ai/admin.py @@ -0,0 +1,116 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +from typing import List, Mapping, Union + +import oracledb + +from .db import cursor +from .sql import ( + DISABLE_AI_PROFILE_DOMAIN_FOR_USER, + ENABLE_AI_PROFILE_DOMAIN_FOR_USER, + GRANT_PRIVILEGES_TO_USER, + REVOKE_PRIVILEGES_FROM_USER, +) + +__all__ = [ + "create_credential", + "disable_provider", + "enable_provider", +] + + +def create_credential(credential: Mapping, replace: bool = False): + """ + Creates a credential object using DBMS_CLOUD.CREATE_CREDENTIAL + + if replace is True, credential will be replaced if it "already exists" + + """ + valid_keys = { + "credential_name", + "username", + "password", + "user_ocid", + "tenancy_ocid", + "private_key", + "fingerprint", + "comments", + } + for k in credential.keys(): + if k.lower() not in valid_keys: + raise ValueError( + f"Invalid value {k}: {credential[k]} for credential object" + ) + + with cursor() as cr: + try: + cr.callproc( + "DBMS_CLOUD.CREATE_CREDENTIAL", keyword_parameters=credential + ) + except oracledb.DatabaseError as e: + (error,) = e.args + # If already exists and replace is True then drop and recreate + if "already exists" in error.message.lower() and replace: + cr.callproc( + "DBMS_CLOUD.DROP_CREDENTIAL", + keyword_parameters={ + "credential_name": credential["credential_name"] + }, + ) + cr.callproc( + "DBMS_CLOUD.CREATE_CREDENTIAL", + keyword_parameters=credential, + ) + else: + raise + + +def enable_provider( + users: Union[str, List[str]], provider_endpoint: str = None +): + """ + Enables AI profile for the user. This method grants execute privilege + on the packages DBMS_CLOUD, DBMS_CLOUD_AI and DBMS_CLOUD_PIPELINE. It + also enables the user to invoke the AI(LLM) endpoint hosted at a + certain domain + """ + if isinstance(users, str): + users = [users] + + with cursor() as cr: + for user in users: + cr.execute(GRANT_PRIVILEGES_TO_USER.format(user)) + if provider_endpoint: + cr.execute( + ENABLE_AI_PROFILE_DOMAIN_FOR_USER, + user=user, + host=provider_endpoint, + ) + + +def disable_provider( + users: Union[str, List[str]], provider_endpoint: str = None +): + """ + Disables AI provider for the user. This method revokes execute privilege + on the packages DBMS_CLOUD, DBMS_CLOUD_AI and DBMS_CLOUD_PIPELINE. It + also disables the user to invoke the AI(LLM) endpoint hosted at a + certain domain + """ + if isinstance(users, str): + users = [users] + + with cursor() as cr: + for user in users: + cr.execute(REVOKE_PRIVILEGES_FROM_USER.format(user)) + if provider_endpoint: + cr.execute( + DISABLE_AI_PROFILE_DOMAIN_FOR_USER, + user=user, + host=provider_endpoint, + ) diff --git a/src/select_ai/async_profile.py b/src/select_ai/async_profile.py new file mode 100644 index 0000000..a00b7ad --- /dev/null +++ b/src/select_ai/async_profile.py @@ -0,0 +1,503 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import json +from contextlib import asynccontextmanager +from dataclasses import replace as dataclass_replace +from typing import ( + AsyncGenerator, + List, + Mapping, + Optional, + Tuple, + Union, +) + +import oracledb +import pandas + +from select_ai.action import Action +from select_ai.base_profile import BaseProfile, ProfileAttributes +from select_ai.conversation import AsyncConversation +from select_ai.db import async_cursor, async_get_connection +from select_ai.errors import ProfileExistsError, ProfileNotFoundError +from select_ai.provider import Provider +from select_ai.sql import ( + GET_USER_AI_PROFILE, + GET_USER_AI_PROFILE_ATTRIBUTES, + LIST_USER_AI_PROFILES, +) +from select_ai.synthetic_data import SyntheticDataAttributes + +__all__ = ["AsyncProfile"] + + +class AsyncProfile(BaseProfile): + """AsyncProfile defines methods to interact with the underlying AI Provider + asynchronously. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._init_coroutine = self._init_profile() + + def __await__(self): + coroutine = self._init_coroutine + return coroutine.__await__() + + async def _init_profile(self): + """Initializes AI profile based on the passed attributes + + :return: None + :raises: oracledb.DatabaseError + """ + if self.profile_name is not None: + profile_exists = False + try: + saved_attributes = await self._get_attributes( + profile_name=self.profile_name + ) + profile_exists = True + if not self.replace and not self.merge: + if ( + self.attributes is not None + or self.description is not None + ): + if self.raise_error_if_exists: + raise ProfileExistsError(self.profile_name) + + if self.description is None: + self.description = await self._get_profile_description( + profile_name=self.profile_name + ) + except ProfileNotFoundError: + if self.attributes is None: + raise + else: + if self.attributes is None: + self.attributes = saved_attributes + if self.merge: + self.replace = True + if self.attributes is not None: + self.attributes = dataclass_replace( + saved_attributes, + **self.attributes.dict(exclude_null=True), + ) + if self.replace or not profile_exists: + await self.create( + replace=self.replace, description=self.description + ) + return self + + @staticmethod + async def _get_profile_description(profile_name) -> str: + """Get description of profile from USER_CLOUD_AI_PROFILES + + :param str profile_name: Name of profile + :return: Description of profile + :rtype: str + :raises: ProfileNotFoundError + + """ + async with async_cursor() as cr: + await cr.execute( + GET_USER_AI_PROFILE, + profile_name=profile_name.upper(), + ) + profile = await cr.fetchone() + if profile: + return await profile[1].read() + else: + raise ProfileNotFoundError(profile_name) + + @staticmethod + async def _get_attributes(profile_name) -> ProfileAttributes: + """Asynchronously gets AI profile attributes from the Database + + :param str profile_name: Name of the profile + :return: select_ai.provider.ProviderAttributes + :raises: ProfileNotFoundError + + """ + async with async_cursor() as cr: + await cr.execute( + GET_USER_AI_PROFILE_ATTRIBUTES, + profile_name=profile_name.upper(), + ) + attributes = await cr.fetchall() + if attributes: + return await ProfileAttributes.async_create(**dict(attributes)) + else: + raise ProfileNotFoundError(profile_name=profile_name) + + async def get_attributes(self) -> ProfileAttributes: + """Asynchronously gets AI profile attributes from the Database + + :return: select_ai.provider.ProviderAttributes + :raises: ProfileNotFoundError + """ + return await self._get_attributes(profile_name=self.profile_name) + + async def _set_attribute( + self, + attribute_name: str, + attribute_value: Union[bool, str, int, float], + ): + parameters = { + "profile_name": self.profile_name, + "attribute_name": attribute_name, + "attribute_value": attribute_value, + } + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.SET_ATTRIBUTE", keyword_parameters=parameters + ) + + async def set_attribute( + self, + attribute_name: str, + attribute_value: Union[bool, str, int, float, Provider], + ): + """Updates AI profile attribute on the Python object and also + saves it in the database + + :param str attribute_name: Name of the AI profile attribute + :param Union[bool, str, int, float] attribute_value: Value of the + profile attribute + :return: None + + """ + self.attributes.set_attribute(attribute_name, attribute_value) + if isinstance(attribute_value, Provider): + for k, v in attribute_value.dict().items(): + await self._set_attribute(k, v) + else: + await self._set_attribute(attribute_name, attribute_value) + + async def set_attributes(self, attributes: ProfileAttributes): + """Updates AI profile attributes on the Python object and also + saves it in the database + + :param ProfileAttributes attributes: Object specifying AI profile + attributes + :return: None + """ + self.attributes = attributes + parameters = { + "profile_name": self.profile_name, + "attributes": self.attributes.json(), + } + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.SET_ATTRIBUTES", keyword_parameters=parameters + ) + + async def create( + self, replace: Optional[int] = False, description: Optional[str] = None + ) -> None: + """Asynchronously create an AI Profile in the Database + + :param bool replace: Set True to replace else False + :param description: The profile description + :return: None + :raises: oracledb.DatabaseError + """ + parameters = { + "profile_name": self.profile_name, + "attributes": self.attributes.json(), + } + if description: + parameters["description"] = description + + async with async_cursor() as cr: + try: + await cr.callproc( + "DBMS_CLOUD_AI.CREATE_PROFILE", + keyword_parameters=parameters, + ) + except oracledb.DatabaseError as e: + (error,) = e.args + # If already exists and replace is True then drop and recreate + if "already exists" in error.message.lower() and replace: + await self.delete(force=True) + await cr.callproc( + "DBMS_CLOUD_AI.CREATE_PROFILE", + keyword_parameters=parameters, + ) + else: + raise + + async def delete(self, force=False) -> None: + """Asynchronously deletes an AI profile from the database + + :param bool force: Ignores errors if AI profile does not exist. + :return: None + :raises: oracledb.DatabaseError + + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.DROP_PROFILE", + keyword_parameters={ + "profile_name": self.profile_name, + "force": force, + }, + ) + + @classmethod + async def _from_db(cls, profile_name: str) -> "AsyncProfile": + """Asynchronously create an AI Profile object from attributes + saved in the database against the profile + + :param str profile_name: + :return: select_ai.Profile + :raises: ProfileNotFoundError + """ + async with async_cursor() as cr: + await cr.execute( + GET_USER_AI_PROFILE_ATTRIBUTES, profile_name=profile_name + ) + attributes = await cr.fetchall() + if attributes: + attributes = await ProfileAttributes.async_create( + **dict(attributes) + ) + return cls(profile_name=profile_name, attributes=attributes) + else: + raise ProfileNotFoundError(profile_name=profile_name) + + @classmethod + async def list( + cls, profile_name_pattern: str = ".*" + ) -> AsyncGenerator["AsyncProfile", None]: + """Asynchronously list AI Profiles saved in the database. + + :param str profile_name_pattern: Regular expressions can be used + to specify a pattern. Function REGEXP_LIKE is used to perform the + match. Default value is ".*" i.e. match all AI profiles. + + :return: Iterator[Profile] + """ + async with async_cursor() as cr: + await cr.execute( + LIST_USER_AI_PROFILES, + profile_name_pattern=profile_name_pattern, + ) + rows = await cr.fetchall() + for row in rows: + profile_name = row[0] + description = row[1] + attributes = await cls._get_attributes( + profile_name=profile_name + ) + yield cls( + profile_name=profile_name, + description=description, + attributes=attributes, + raise_error_if_exists=False, + ) + + async def generate( + self, prompt, action=Action.SHOWSQL, params: Mapping = None + ) -> Union[pandas.DataFrame, str, None]: + """Asynchronously perform AI translation using this profile + + :param str prompt: Natural language prompt to translate + :param select_ai.profile.Action action: + :param params: Parameters to include in the LLM request. For e.g. + conversation_id for context-aware chats + :return: Union[pandas.DataFrame, str] + """ + parameters = { + "prompt": prompt, + "action": action, + "profile_name": self.profile_name, + # "attributes": self.attributes.json(), + } + if params: + parameters["params"] = json.dumps(params) + + async with async_cursor() as cr: + data = await cr.callfunc( + "DBMS_CLOUD_AI.GENERATE", + oracledb.DB_TYPE_CLOB, + keyword_parameters=parameters, + ) + if data is not None: + return await data.read() + return None + + async def chat(self, prompt, params: Mapping = None) -> str: + """Asynchronously chat with the LLM + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return await self.generate(prompt, action=Action.CHAT, params=params) + + @asynccontextmanager + async def chat_session( + self, conversation: AsyncConversation, delete: bool = False + ): + """Starts a new chat session for context-aware conversations + + :param AsyncConversation conversation: Conversation object to use for this + chat session + :param bool delete: Delete conversation after session ends + + """ + try: + if ( + conversation.conversation_id is None + and conversation.attributes is not None + ): + await conversation.create() + params = {"conversation_id": conversation.conversation_id} + async_session = AsyncSession(async_profile=self, params=params) + yield async_session + finally: + if delete: + await conversation.delete() + + async def narrate(self, prompt, params: Mapping = None) -> str: + """Narrate the result of the SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return await self.generate( + prompt, action=Action.NARRATE, params=params + ) + + async def explain_sql(self, prompt: str, params: Mapping = None): + """Explain the generated SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return await self.generate( + prompt, action=Action.EXPLAINSQL, params=params + ) + + async def run_sql( + self, prompt, params: Mapping = None + ) -> pandas.DataFrame: + """Explain the generated SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: pandas.DataFrame + """ + data = await self.generate(prompt, action=Action.RUNSQL, params=params) + return pandas.DataFrame(json.loads(data)) + + async def show_sql(self, prompt, params: Mapping = None): + """Show the generated SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return await self.generate( + prompt, action=Action.SHOWSQL, params=params + ) + + async def show_prompt(self, prompt: str, params: Mapping = None): + """Show the prompt sent to LLM + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return await self.generate( + prompt, action=Action.SHOWPROMPT, params=params + ) + + async def generate_synthetic_data( + self, synthetic_data_attributes: SyntheticDataAttributes + ) -> None: + """Generate synthetic data for a single table, multiple tables or a + full schema. + + :param select_ai.SyntheticDataAttributes synthetic_data_attributes: + :return: None + :raises: oracledb.DatabaseError + + """ + keyword_parameters = synthetic_data_attributes.prepare() + keyword_parameters["profile_name"] = self.profile_name + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.GENERATE_SYNTHETIC_DATA", + keyword_parameters=keyword_parameters, + ) + + async def run_pipeline( + self, + prompt_specifications: List[Tuple[str, Action]], + continue_on_error: bool = False, + ) -> List[Union[str, pandas.DataFrame]]: + """Send Multiple prompts in a single roundtrip to the Database + + :param List[Tuple[str, Action]] prompt_specifications: List of + 2-element tuples. First element is the prompt and second is the + corresponding action + + :param bool continue_on_error: True to continue on error else False + :return: List[Union[str, pandas.DataFrame]] + """ + pipeline = oracledb.create_pipeline() + for prompt, action in prompt_specifications: + parameters = { + "prompt": prompt, + "action": action, + "profile_name": self.profile_name, + # "attributes": self.attributes.json(), + } + pipeline.add_callfunc( + "DBMS_CLOUD_AI.GENERATE", + return_type=oracledb.DB_TYPE_CLOB, + keyword_parameters=parameters, + ) + async_connection = await async_get_connection() + pipeline_results = await async_connection.run_pipeline( + pipeline, continue_on_error=continue_on_error + ) + responses = [] + for result in pipeline_results: + if not result.error: + responses.append(await result.return_value.read()) + else: + responses.append(result.error) + return responses + + +class AsyncSession: + """AsyncSession lets you persist request parameters across DBMS_CLOUD_AI + requests. This is useful in context-aware conversations + """ + + def __init__(self, async_profile: AsyncProfile, params: Mapping): + """ + + :param async_profile: An AI Profile to use in this session + :param params: Parameters to be persisted across requests + """ + self.params = params + self.async_profile = async_profile + + async def chat(self, prompt: str): + return await self.async_profile.chat(prompt=prompt, params=self.params) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/src/select_ai/base_profile.py b/src/select_ai/base_profile.py new file mode 100644 index 0000000..431b792 --- /dev/null +++ b/src/select_ai/base_profile.py @@ -0,0 +1,179 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import json +from abc import ABC +from dataclasses import dataclass +from typing import List, Mapping, Optional + +import oracledb + +from select_ai._abc import SelectAIDataClass + +from .provider import Provider + + +@dataclass +class ProfileAttributes(SelectAIDataClass): + """ + Use this class to define attributes to manage and configure the behavior of + an AI profile + + :param bool comments: True to include column comments in the metadata used + for generating SQL queries from natural language prompts. + :param bool constraints: True to include referential integrity constraints + such as primary and foreign keys in the metadata sent to the LLM. + :param bool conversation: Indicates if conversation history is enabled for + a profile. + :param str credential_name: The name of the credential to access the AI + provider APIs. + :param bool enforce_object_list: Specifies whether to restrict the LLM + to generate SQL that uses only tables covered by the object list. + :param int max_tokens: Denotes the number of tokens to return per + generation. Default is 1024. + :param List[Mapping] object_list: Array of JSON objects specifying + the owner and object names that are eligible for natural language + translation to SQL. + :param str object_list_mode: Specifies whether to send metadata for the + most relevant tables or all tables to the LLM. Supported values are - + 'automated' and 'all' + :param select_ai.Provider provider: AI Provider + :param str stop_tokens: The generated text will be terminated at the + beginning of the earliest stop sequence. Sequence will be incorporated + into the text. The attribute value must be a valid array of string values + in JSON format + :param float temperature: Temperature is a non-negative float number used + to tune the degree of randomness. Lower temperatures mean less random + generations. + :param str vector_index_name: Name of the vector index + + """ + + annotations: Optional[str] = None + case_sensitive_values: Optional[bool] = None + comments: Optional[bool] = None + constraints: Optional[str] = None + conversation: Optional[bool] = None + credential_name: Optional[str] = None + enable_sources: Optional[bool] = None + enable_source_offsets: Optional[bool] = None + enforce_object_list: Optional[bool] = None + max_tokens: Optional[int] = 1024 + object_list: Optional[List[Mapping]] = None + object_list_mode: Optional[str] = None + provider: Optional[Provider] = None + seed: Optional[str] = None + stop_tokens: Optional[str] = None + streaming: Optional[str] = None + temperature: Optional[float] = None + vector_index_name: Optional[str] = None + + def __post_init__(self): + if not isinstance(self.provider, Provider): + raise ValueError( + f"The arg `provider` must be an object of " + f"type select_ai.Provider" + ) + + def json(self, exclude_null=True): + attributes = {} + for k, v in self.dict(exclude_null=exclude_null).items(): + if isinstance(v, Provider): + for provider_k, provider_v in v.dict( + exclude_null=exclude_null + ).items(): + attributes[Provider.key_alias(provider_k)] = provider_v + else: + attributes[k] = v + return json.dumps(attributes) + + @classmethod + def create(cls, **kwargs): + provider_attributes = {} + profile_attributes = {} + for k, v in kwargs.items(): + if isinstance(v, oracledb.LOB): + v = v.read() + if k in Provider.keys(): + provider_attributes[Provider.key_alias(k)] = v + else: + profile_attributes[k] = v + provider = Provider.create(**provider_attributes) + profile_attributes["provider"] = provider + return ProfileAttributes(**profile_attributes) + + @classmethod + async def async_create(cls, **kwargs): + provider_attributes = {} + profile_attributes = {} + for k, v in kwargs.items(): + if isinstance(v, oracledb.AsyncLOB): + v = await v.read() + if k in Provider.keys(): + provider_attributes[Provider.key_alias(k)] = v + else: + profile_attributes[k] = v + provider = Provider.create(**provider_attributes) + profile_attributes["provider"] = provider + return ProfileAttributes(**profile_attributes) + + def set_attribute(self, key, value): + if key in Provider.keys() and not isinstance(value, Provider): + setattr(self.provider, key, value) + else: + setattr(self, key, value) + + +class BaseProfile(ABC): + """ + BaseProfile is an abstract base class representing a Profile + for Select AI's interactions with AI service providers (LLMs). + Use either select_ai.Profile or select_ai.AsyncProfile to + instantiate an AI profile object. + + :param str profile_name : Name of the profile + + :param select_ai.ProfileAttributes attributes: + Object specifying AI profile attributes + + :param str description: Description of the profile + + :param bool merge: Fetches the profile + from database, merges the non-null attributes and saves it back + in the database. Default value is False + + :param bool replace: Replaces the profile and attributes + in the database. Default value is False + + :param bool raise_error_if_exists: Raise ProfileExistsError + if profile exists in the database and replace = False and + merge = False. Default value is True + + """ + + def __init__( + self, + profile_name: Optional[str] = None, + attributes: Optional[ProfileAttributes] = None, + description: Optional[str] = None, + merge: Optional[bool] = False, + replace: Optional[bool] = False, + raise_error_if_exists: Optional[bool] = True, + ): + """Initialize a base profile""" + self.profile_name = profile_name + self.attributes = attributes + self.description = description + self.merge = merge + self.replace = replace + self.raise_error_if_exists = raise_error_if_exists + + def __repr__(self): + return ( + f"{self.__class__.__name__}(profile_name={self.profile_name}, " + f"attributes={self.attributes}, description={self.description})" + ) diff --git a/src/select_ai/conversation.py b/src/select_ai/conversation.py new file mode 100644 index 0000000..600a52b --- /dev/null +++ b/src/select_ai/conversation.py @@ -0,0 +1,262 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import datetime +import json +from dataclasses import dataclass +from typing import AsyncGenerator, Iterator, Optional + +import oracledb + +from select_ai._abc import SelectAIDataClass +from select_ai.db import async_cursor, cursor +from select_ai.errors import ConversationNotFoundError +from select_ai.sql import ( + GET_USER_CONVERSATION_ATTRIBUTES, + LIST_USER_CONVERSATIONS, +) + +__all__ = ["AsyncConversation", "Conversation", "ConversationAttributes"] + + +@dataclass +class ConversationAttributes(SelectAIDataClass): + """Conversation Attributes + + :param str title: Conversation Title + :param str description: Description of the conversation topic + :param datetime.timedelta retention_days: The number of days the conversation + will be stored in the database from its creation date. If value is 0, the + conversation will not be removed unless it is manually deleted by + delete + :param int conversation_length: Number of prompts to store for this + conversation + + """ + + title: Optional[str] = "New Conversation" + description: Optional[str] = None + retention_days: Optional[datetime.timedelta] = datetime.timedelta(days=7) + conversation_length: Optional[int] = 10 + + def json(self, exclude_null=True): + attributes = {} + for k, v in self.dict(exclude_null=exclude_null).items(): + if isinstance(v, datetime.timedelta): + attributes[k] = v.days + else: + attributes[k] = v + return json.dumps(attributes) + + +class _BaseConversation: + + def __init__( + self, + conversation_id: Optional[str] = None, + attributes: Optional[ConversationAttributes] = None, + ): + self.conversation_id = conversation_id + self.attributes = attributes + + def __repr__(self): + return ( + f"{self.__class__.__name__}(conversation_id={self.conversation_id}, " + f"attributes={self.attributes})" + ) + + +class Conversation(_BaseConversation): + """Conversation class can be used to create, update and delete + conversations in the database + + Typical usage is to combine this conversation object with an AI + Profile.chat_session() to have context-aware conversations with + the LLM provider + + :param str conversation_id: Conversation ID + :param ConversationAttributes attributes: Conversation attributes + + """ + + def create(self) -> str: + """Creates a new conversation and returns the conversation_id + to be used in context-aware conversations with LLMs + + :return: conversation_id + """ + with cursor() as cr: + self.conversation_id = cr.callfunc( + "DBMS_CLOUD_AI.CREATE_CONVERSATION", + oracledb.DB_TYPE_VARCHAR, + keyword_parameters={"attributes": self.attributes.json()}, + ) + return self.conversation_id + + def delete(self, force: bool = False): + """Drops the conversation""" + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.DROP_CONVERSATION", + keyword_parameters={ + "conversation_id": self.conversation_id, + "force": force, + }, + ) + + def set_attributes(self, attributes: ConversationAttributes): + """Updates the attributes of the conversation in the database""" + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.UPDATE_CONVERSATION", + keyword_parameters={ + "conversation_id": self.conversation_id, + "attributes": attributes.json(), + }, + ) + + def get_attributes(self) -> ConversationAttributes: + """Get attributes of the conversation from the database""" + with cursor() as cr: + cr.execute( + GET_USER_CONVERSATION_ATTRIBUTES, + conversation_id=self.conversation_id, + ) + attributes = cr.fetchone() + if attributes: + conversation_title = attributes[0] + description = attributes[1].read() # Oracle.LOB + retention_days = attributes[2] + return ConversationAttributes( + title=conversation_title, + description=description, + retention_days=retention_days, + ) + else: + raise ConversationNotFoundError( + conversation_id=self.conversation_id + ) + + @classmethod + def list(cls) -> Iterator["Conversation"]: + """List all conversations + + :return: Iterator[VectorIndex] + """ + with cursor() as cr: + cr.execute( + LIST_USER_CONVERSATIONS, + ) + for row in cr.fetchall(): + conversation_id = row[0] + conversation_title = row[1] + description = row[2].read() # Oracle.LOB + retention_days = row[3] + attributes = ConversationAttributes( + title=conversation_title, + description=description, + retention_days=retention_days, + ) + yield cls( + attributes=attributes, conversation_id=conversation_id + ) + + +class AsyncConversation(_BaseConversation): + """AsyncConversation class can be used to create, update and delete + conversations in the database in an async manner + + Typical usage is to combine this conversation object with an + AsyncProfile.chat_session() to have context-aware conversations + + :param str conversation_id: Conversation ID + :param ConversationAttributes attributes: Conversation attributes + + """ + + async def create(self) -> str: + """Creates a new conversation and returns the conversation_id + to be used in context-aware conversations with LLMs + + :return: conversation_id + """ + async with async_cursor() as cr: + self.conversation_id = await cr.callfunc( + "DBMS_CLOUD_AI.CREATE_CONVERSATION", + oracledb.DB_TYPE_VARCHAR, + keyword_parameters={"attributes": self.attributes.json()}, + ) + return self.conversation_id + + async def delete(self, force: bool = False): + """Delete the conversation""" + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.DROP_CONVERSATION", + keyword_parameters={ + "conversation_id": self.conversation_id, + "force": force, + }, + ) + + async def set_attributes(self, attributes: ConversationAttributes): + """Updates the attributes of the conversation""" + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.UPDATE_CONVERSATION", + keyword_parameters={ + "conversation_id": self.conversation_id, + "attributes": attributes.json(), + }, + ) + + async def get_attributes(self) -> ConversationAttributes: + """Get attributes of the conversation from the database""" + async with async_cursor() as cr: + await cr.execute( + GET_USER_CONVERSATION_ATTRIBUTES, + conversation_id=self.conversation_id, + ) + attributes = await cr.fetchone() + if attributes: + conversation_title = attributes[0] + description = await attributes[1].read() # Oracle.AsyncLOB + retention_days = attributes[2] + return ConversationAttributes( + title=conversation_title, + description=description, + retention_days=retention_days, + ) + else: + raise ConversationNotFoundError( + conversation_id=self.conversation_id + ) + + @classmethod + async def list(cls) -> AsyncGenerator["AsyncConversation", None]: + """List all conversations + + :return: Iterator[VectorIndex] + """ + async with async_cursor() as cr: + await cr.execute( + LIST_USER_CONVERSATIONS, + ) + rows = await cr.fetchall() + for row in rows: + conversation_id = row[0] + conversation_title = row[1] + description = await row[2].read() # Oracle.AsyncLOB + retention_days = row[3] + attributes = ConversationAttributes( + title=conversation_title, + description=description, + retention_days=retention_days, + ) + yield cls( + attributes=attributes, conversation_id=conversation_id + ) diff --git a/src/select_ai/db.py b/src/select_ai/db.py new file mode 100644 index 0000000..aa10986 --- /dev/null +++ b/src/select_ai/db.py @@ -0,0 +1,186 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import contextlib +import os +from threading import get_ident +from typing import Dict, Hashable + +import oracledb + +from select_ai.errors import DatabaseNotConnectedError + +__conn__: Dict[Hashable, oracledb.Connection] = {} +__async_conn__: Dict[Hashable, oracledb.AsyncConnection] = {} + +__all__ = [ + "connect", + "async_connect", + "is_connected", + "async_is_connected", + "get_connection", + "async_get_connection", + "cursor", + "async_cursor", + "disconnect", + "async_disconnect", +] + + +def connect(user: str, password: str, dsn: str, *args, **kwargs): + """Creates an oracledb.Connection object + and saves it global dictionary __conn__ + The connection object is thread local meaning + in a multithreaded application, individual + threads cannot see each other's connection + object + """ + conn = oracledb.connect( + user=user, + password=password, + dsn=dsn, + connection_id_prefix="python-select-ai", + *args, + **kwargs, + ) + _set_connection(conn=conn) + + +async def async_connect(user: str, password: str, dsn: str, *args, **kwargs): + """Creates an oracledb.AsyncConnection object + and saves it global dictionary __async_conn__ + The connection object is thread local meaning + in a multithreaded application, individual + threads cannot see each other's connection + object + """ + async_conn = await oracledb.connect_async( + user=user, password=password, dsn=dsn, *args, **kwargs + ) + _set_connection(async_conn=async_conn) + + +def is_connected() -> bool: + """Checks if database connection is open and healthy""" + global __conn__ + key = (os.getpid(), get_ident()) + conn = __conn__.get(key) + if conn is None: + return False + try: + return conn.ping() is None + except oracledb.DatabaseError: + return False + + +async def async_is_connected() -> bool: + """Asynchronously checks if database connection is open and healthy""" + + global __async_conn__ + key = (os.getpid(), get_ident()) + conn = __async_conn__.get(key) + if conn is None: + return False + try: + return await conn.ping() is None + except oracledb.DatabaseError: + return False + + +def _set_connection( + conn: oracledb.Connection = None, + async_conn: oracledb.AsyncConnection = None, +): + """Set existing connection for select_ai Python API to reuse + + :param conn: python-oracledb Connection object + :param async_conn: python-oracledb + :return: + """ + key = (os.getpid(), get_ident()) + if conn: + global __conn__ + __conn__[key] = conn + if async_conn: + global __async_conn__ + __async_conn__[key] = async_conn + + +def get_connection() -> oracledb.Connection: + """Returns the connection object if connection is healthy""" + if not is_connected(): + raise DatabaseNotConnectedError() + global __conn__ + key = (os.getpid(), get_ident()) + return __conn__[key] + + +async def async_get_connection() -> oracledb.AsyncConnection: + """Returns the AsyncConnection object if connection is healthy""" + if not await async_is_connected(): + raise DatabaseNotConnectedError() + global __async_conn__ + key = (os.getpid(), get_ident()) + return __async_conn__[key] + + +@contextlib.contextmanager +def cursor(): + """ + Creates a context manager for database cursor + + Typical usage: + + with select_ai.cursor() as cr: + cr.execute() + + This ensures that the cursor is closed regardless + of whether an exception occurred + + """ + cr = get_connection().cursor() + try: + yield cr + finally: + cr.close() + + +@contextlib.asynccontextmanager +async def async_cursor(): + """ + Creates an async context manager for database cursor + + Typical usage: + + async with select_ai.cursor() as cr: + await cr.execute() + :return: + """ + conn = await async_get_connection() + cr = conn.cursor() + try: + yield cr + finally: + cr.close() + + +def disconnect(): + try: + conn = get_connection() + except DatabaseNotConnectedError: + pass + else: + conn.close() + + +async def async_disconnect(): + try: + conn = await async_get_connection() + except DatabaseNotConnectedError: + pass + else: + await conn.close() diff --git a/src/select_ai/errors.py b/src/select_ai/errors.py new file mode 100644 index 0000000..af7dbc9 --- /dev/null +++ b/src/select_ai/errors.py @@ -0,0 +1,73 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + + +class SelectAIError(Exception): + """Base class for any SelectAIErrors""" + + pass + + +class DatabaseNotConnectedError(SelectAIError): + """Raised when a database is not connected""" + + def __str__(self): + return ( + "Not connected to the Database. " + "Use select_ai.connect() or select_ai.async_connect() " + "to establish connection" + ) + + +class ConversationNotFoundError(SelectAIError): + """Conversation not found in the database""" + + def __init__(self, conversation_id: str): + self.conversation_id = conversation_id + + def __str__(self): + return f"Conversation with id {self.conversation_id} not found" + + +class ProfileNotFoundError(SelectAIError): + """Profile not found in the database""" + + def __init__(self, profile_name: str): + self.profile_name = profile_name + + def __str__(self): + return f"Profile {self.profile_name} not found" + + +class ProfileExistsError(SelectAIError): + """Profile already exists in the database""" + + def __init__(self, profile_name: str): + self.profile_name = profile_name + + def __str__(self): + return ( + f"Profile {self.profile_name} already exists. " + f"Use either replace=True or merge=True" + ) + + +class VectorIndexNotFoundError(SelectAIError): + """VectorIndex not found in the database""" + + def __init__(self, index_name: str, profile_name: str = None): + self.index_name = index_name + self.profile_name = profile_name + + def __str__(self): + if self.profile_name: + return ( + f"VectorIndex {self.index_name} " + f"not found for profile {self.profile_name}" + ) + else: + return f"VectorIndex {self.index_name} not found" diff --git a/src/select_ai/profile.py b/src/select_ai/profile.py new file mode 100644 index 0000000..45b4e2a --- /dev/null +++ b/src/select_ai/profile.py @@ -0,0 +1,427 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import json +from contextlib import contextmanager +from dataclasses import replace as dataclass_replace +from typing import Iterator, Mapping, Optional, Union + +import oracledb +import pandas + +from select_ai import Conversation +from select_ai.action import Action +from select_ai.base_profile import BaseProfile, ProfileAttributes +from select_ai.db import cursor +from select_ai.errors import ProfileExistsError, ProfileNotFoundError +from select_ai.provider import Provider +from select_ai.sql import ( + GET_USER_AI_PROFILE, + GET_USER_AI_PROFILE_ATTRIBUTES, + LIST_USER_AI_PROFILES, +) +from select_ai.synthetic_data import SyntheticDataAttributes + + +class Profile(BaseProfile): + """Profile class represents an AI Profile. It defines + attributes and methods to interact with the underlying + AI Provider. All methods in this class are synchronous + or blocking + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._init_profile() + + def _init_profile(self) -> None: + """Initializes AI profile based on the passed attributes + + :return: None + :raises: oracledb.DatabaseError + """ + if self.profile_name is not None: + profile_exists = False + try: + saved_attributes = self._get_attributes( + profile_name=self.profile_name + ) + profile_exists = True + if not self.replace and not self.merge: + if ( + self.attributes is not None + or self.description is not None + ): + if self.raise_error_if_exists: + raise ProfileExistsError(self.profile_name) + + if self.description is None: + self.description = self._get_profile_description( + profile_name=self.profile_name + ) + except ProfileNotFoundError: + if self.attributes is None: + raise + else: + if self.attributes is None: + self.attributes = saved_attributes + if self.merge: + self.replace = True + if self.attributes is not None: + self.attributes = dataclass_replace( + saved_attributes, + **self.attributes.dict(exclude_null=True), + ) + if self.replace or not profile_exists: + self.create(replace=self.replace) + + @staticmethod + def _get_profile_description(profile_name) -> str: + """Get description of profile from USER_CLOUD_AI_PROFILES + + :param str profile_name: + :return: str + :raises: ProfileNotFoundError + """ + with cursor() as cr: + cr.execute(GET_USER_AI_PROFILE, profile_name=profile_name.upper()) + profile = cr.fetchone() + if profile: + return profile[1].read() + else: + raise ProfileNotFoundError(profile_name) + + @staticmethod + def _get_attributes(profile_name) -> ProfileAttributes: + """Get AI profile attributes from the Database + + :param str profile_name: Name of the profile + :return: select_ai.ProfileAttributes + :raises: ProfileNotFoundError + """ + with cursor() as cr: + cr.execute( + GET_USER_AI_PROFILE_ATTRIBUTES, + profile_name=profile_name.upper(), + ) + attributes = cr.fetchall() + if attributes: + return ProfileAttributes.create(**dict(attributes)) + else: + raise ProfileNotFoundError(profile_name=profile_name) + + def get_attributes(self) -> ProfileAttributes: + """Get AI profile attributes from the Database + + :return: select_ai.ProfileAttributes + """ + return self._get_attributes(profile_name=self.profile_name) + + def _set_attribute( + self, + attribute_name: str, + attribute_value: Union[bool, str, int, float], + ): + parameters = { + "profile_name": self.profile_name, + "attribute_name": attribute_name, + "attribute_value": attribute_value, + } + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.SET_ATTRIBUTE", keyword_parameters=parameters + ) + + def set_attribute( + self, + attribute_name: str, + attribute_value: Union[bool, str, int, float, Provider], + ): + """Updates AI profile attribute on the Python object and also + saves it in the database + + :param str attribute_name: Name of the AI profile attribute + :param Union[bool, str, int, float, Provider] attribute_value: Value of + the profile attribute + :return: None + + """ + self.attributes.set_attribute(attribute_name, attribute_value) + if isinstance(attribute_value, Provider): + for k, v in attribute_value.dict().items(): + self._set_attribute(k, v) + else: + self._set_attribute(attribute_name, attribute_value) + + def set_attributes(self, attributes: ProfileAttributes): + """Updates AI profile attributes on the Python object and also + saves it in the database + + :param ProviderAttributes attributes: Object specifying AI profile + attributes + :return: None + """ + self.attributes = attributes + parameters = { + "profile_name": self.profile_name, + "attributes": self.attributes.json(), + } + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.SET_ATTRIBUTES", keyword_parameters=parameters + ) + + def create(self, replace: Optional[int] = False) -> None: + """Create an AI Profile in the Database + + :param bool replace: Set True to replace else False + :return: None + :raises: oracledb.DatabaseError + """ + + parameters = { + "profile_name": self.profile_name, + "attributes": self.attributes.json(), + } + if self.description: + parameters["description"] = self.description + + with cursor() as cr: + try: + cr.callproc( + "DBMS_CLOUD_AI.CREATE_PROFILE", + keyword_parameters=parameters, + ) + except oracledb.DatabaseError as e: + (error,) = e.args + # If already exists and replace is True then drop and recreate + if "already exists" in error.message.lower() and replace: + self.delete(force=True) + cr.callproc( + "DBMS_CLOUD_AI.CREATE_PROFILE", + keyword_parameters=parameters, + ) + else: + raise + + def delete(self, force=False) -> None: + """Deletes an AI profile from the database + + :param bool force: Ignores errors if AI profile does not exist. + :return: None + :raises: oracledb.DatabaseError + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.DROP_PROFILE", + keyword_parameters={ + "profile_name": self.profile_name, + "force": force, + }, + ) + + @classmethod + def _from_db(cls, profile_name: str) -> "Profile": + """Create a Profile object from attributes saved in the database + + :param str profile_name: + :return: select_ai.Profile + :raises: ProfileNotFoundError + """ + with cursor() as cr: + cr.execute( + GET_USER_AI_PROFILE_ATTRIBUTES, profile_name=profile_name + ) + attributes = cr.fetchall() + if attributes: + attributes = ProfileAttributes.create(**dict(attributes)) + return cls(profile_name=profile_name, attributes=attributes) + else: + raise ProfileNotFoundError(profile_name=profile_name) + + @classmethod + def list(cls, profile_name_pattern: str = ".*") -> Iterator["Profile"]: + """List AI Profiles saved in the database. + + :param str profile_name_pattern: Regular expressions can be used + to specify a pattern. Function REGEXP_LIKE is used to perform the + match. Default value is ".*" i.e. match all AI profiles. + + :return: Iterator[Profile] + """ + with cursor() as cr: + cr.execute( + LIST_USER_AI_PROFILES, + profile_name_pattern=profile_name_pattern, + ) + for row in cr.fetchall(): + profile_name = row[0] + description = row[1] + attributes = cls._get_attributes(profile_name=profile_name) + yield cls( + profile_name=profile_name, + description=description, + attributes=attributes, + raise_error_if_exists=False, + ) + + def generate( + self, + prompt: str, + action: Optional[Action] = Action.RUNSQL, + params: Mapping = None, + ) -> Union[pandas.DataFrame, str, None]: + """Perform AI translation using this profile + + :param str prompt: Natural language prompt to translate + :param select_ai.profile.Action action: + :param params: Parameters to include in the LLM request. For e.g. + conversation_id for context-aware chats + :return: Union[pandas.DataFrame, str] + """ + parameters = { + "prompt": prompt, + "action": action, + "profile_name": self.profile_name, + # "attributes": self.attributes.json(), + } + if params: + parameters["params"] = json.dumps(params) + with cursor() as cr: + data = cr.callfunc( + "DBMS_CLOUD_AI.GENERATE", + oracledb.DB_TYPE_CLOB, + keyword_parameters=parameters, + ) + if data is not None: + return data.read() + return None + + def chat(self, prompt: str, params: Mapping = None) -> str: + """Chat with the LLM + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return self.generate(prompt, action=Action.CHAT, params=params) + + @contextmanager + def chat_session(self, conversation: Conversation, delete: bool = False): + """Starts a new chat session for context-aware conversations + + :param Conversation conversation: Conversation object to use for this + chat session + :param bool delete: Delete conversation after session ends + + :return: + """ + try: + if ( + conversation.conversation_id is None + and conversation.attributes is not None + ): + conversation.create() + params = {"conversation_id": conversation.conversation_id} + session = Session(profile=self, params=params) + yield session + finally: + if delete: + conversation.delete() + + def narrate(self, prompt: str, params: Mapping = None) -> str: + """Narrate the result of the SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return self.generate(prompt, action=Action.NARRATE, params=params) + + def explain_sql(self, prompt: str, params: Mapping = None) -> str: + """Explain the generated SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return self.generate(prompt, action=Action.EXPLAINSQL, params=params) + + def run_sql(self, prompt: str, params: Mapping = None) -> pandas.DataFrame: + """Run the generate SQL statement and return a pandas Dataframe built + using the result set + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: pandas.DataFrame + """ + data = json.loads( + self.generate(prompt, action=Action.RUNSQL, params=params) + ) + return pandas.DataFrame(data) + + def show_sql(self, prompt: str, params: Mapping = None) -> str: + """Show the generated SQL + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return self.generate(prompt, action=Action.SHOWSQL, params=params) + + def show_prompt(self, prompt: str, params: Mapping = None) -> str: + """Show the prompt sent to LLM + + :param str prompt: Natural language prompt + :param params: Parameters to include in the LLM request + :return: str + """ + return self.generate(prompt, action=Action.SHOWPROMPT, params=params) + + def generate_synthetic_data( + self, synthetic_data_attributes: SyntheticDataAttributes + ) -> None: + """Generate synthetic data for a single table, multiple tables or a + full schema. + + :param select_ai.SyntheticDataAttributes synthetic_data_attributes: + :return: None + :raises: oracledb.DatabaseError + + """ + keyword_parameters = synthetic_data_attributes.prepare() + keyword_parameters["profile_name"] = self.profile_name + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.GENERATE_SYNTHETIC_DATA", + keyword_parameters=keyword_parameters, + ) + + +class Session: + """Session lets you persist request parameters across DBMS_CLOUD_AI + requests. This is useful in context-aware conversations + """ + + def __init__(self, profile: Profile, params: Mapping): + """ + + :param profile: An AI Profile to use in this session + :param params: Parameters to be persisted across requests + """ + self.params = params + self.profile = profile + + def chat(self, prompt: str): + # params = {"conversation_id": self.conversation_id} + return self.profile.chat(prompt=prompt, params=self.params) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/src/select_ai/provider.py b/src/select_ai/provider.py new file mode 100644 index 0000000..ffa3018 --- /dev/null +++ b/src/select_ai/provider.py @@ -0,0 +1,186 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +from dataclasses import dataclass, fields +from typing import Optional + +from select_ai._abc import SelectAIDataClass + +OPENAI = "openai" +COHERE = "cohere" +AZURE = "azure" +OCI = "oci" +GOOGLE = "google" +ANTHROPIC = "anthropic" +HUGGINGFACE = "huggingface" +AWS = "aws" + + +@dataclass +class Provider(SelectAIDataClass): + """ + Base class for AI Provider + + To create an object of Provider class, use any one of the concrete AI + provider implementations + + :param str embedding_model: The embedding model, also known as a + transformer. Depending on the AI provider, the supported embedding models + vary + :param str model: The name of the LLM being used to generate + responses + :param str provider_name: The name of the provider being used + :param str provider_endpoint: Endpoint URL of the AI provider being used + :param str region: The cloud region of the Gen AI cluster + + """ + + embedding_model: Optional[str] = None + model: Optional[str] = None + provider_name: Optional[str] = None + provider_endpoint: Optional[str] = None + region: Optional[str] = None + + @classmethod + def create(cls, *, provider_name: Optional[str] = None, **kwargs): + for subclass in cls.__subclasses__(): + if subclass.provider_name == provider_name: + return subclass(**kwargs) + return cls(**kwargs) + + @classmethod + def key_alias(cls, k): + return {"provider": "provider_name", "provider_name": "provider"}.get( + k, k + ) + + @classmethod + def keys(cls): + return { + "provider", + "provider_name", + "embedding_model", + "model", + "region", + "provider_endpoint", + "azure_deployment_name", + "azure_embedding_deployment_name", + "azure_resource_name", + "oci_apiformat", + "oci_compartment_id", + "oci_endpoint_id", + "oci_runtimetype", + "aws_apiformat", + } + + +@dataclass +class AzureProvider(Provider): + """ + Azure specific attributes + + :param str azure_deployment_name: Name of the Azure OpenAI Service + deployed model. + :param str azure_embedding_deployment_name: Name of the Azure OpenAI + deployed embedding model. + :param str azure_resource_name: Name of the Azure OpenAI Service resource + """ + + provider_name: str = AZURE + azure_deployment_name: Optional[str] = None + azure_embedding_deployment_name: Optional[str] = None + azure_resource_name: Optional[str] = None + + def __post_init__(self): + super().__post_init__() + self.provider_endpoint = f"{self.azure_resource_name}.openai.azure.com" + + +@dataclass +class OpenAIProvider(Provider): + """ + OpenAI specific attributes + """ + + provider_name: str = OPENAI + provider_endpoint: Optional[str] = "api.openai.com" + + +@dataclass +class OCIGenAIProvider(Provider): + """ + OCI Gen AI specific attributes + + :param str oci_apiformat: Specifies the format in which the API expects + data to be sent and received. Supported values are 'COHERE' and 'GENERIC' + :param str oci_compartment_id: Specifies the OCID of the compartment you + are permitted to access when calling the OCI Generative AI service + :param str oci_endpoint_id: This attributes indicates the endpoint OCID + of the Oracle dedicated AI hosting cluster + :param str oci_runtimetype: This attribute indicates the runtime type of + the provided model. The supported values are 'COHERE' and 'LLAMA' + """ + + provider_name: str = OCI + oci_apiformat: Optional[str] = None + oci_compartment_id: Optional[str] = None + oci_endpoint_id: Optional[str] = None + oci_runtimetype: Optional[str] = None + + +@dataclass +class CohereProvider(Provider): + """ + Cohere AI specific attributes + """ + + provider_name: str = COHERE + provider_endpoint = "api.cohere.ai" + + +@dataclass +class GoogleProvider(Provider): + """ + Google AI specific attributes + """ + + provider_name: str = GOOGLE + provider_endpoint = "generativelanguage.googleapis.com" + + +@dataclass +class HuggingFaceProvider(Provider): + """ + HuggingFace specific attributes + """ + + provider_name: str = HUGGINGFACE + provider_endpoint = "api-inference.huggingface.co" + + +@dataclass +class AWSProvider(Provider): + """ + AWS specific attributes + """ + + provider_name: str = AWS + aws_apiformat: Optional[str] = None + + def __post_init__(self): + super().__post_init__() + self.provider_endpoint = f"bedrock-runtime.{self.region}.amazonaws.com" + + +@dataclass +class AnthropicProvider(Provider): + """ + Anthropic specific attributes + """ + + provider_name: str = ANTHROPIC + provider_endpoint = "api.anthropic.com" diff --git a/src/select_ai/sql.py b/src/select_ai/sql.py new file mode 100644 index 0000000..73447e4 --- /dev/null +++ b/src/select_ai/sql.py @@ -0,0 +1,105 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +GRANT_PRIVILEGES_TO_USER = """ +DECLARE + TYPE array_t IS VARRAY(3) OF VARCHAR2(60); + v_packages array_t; +BEGIN + v_packages := array_t( + 'DBMS_CLOUD', 'DBMS_CLOUD_AI', 'DBMS_CLOUD_PIPELINE' + ); + FOR i in 1..v_packages.count LOOP + EXECUTE IMMEDIATE + 'GRANT EXECUTE ON ' || v_packages(i) || ' TO {0}'; + END LOOP; +END; +""" + +REVOKE_PRIVILEGES_FROM_USER = """ +DECLARE + TYPE array_t IS VARRAY(3) OF VARCHAR2(60); + v_packages array_t; +BEGIN + v_packages := array_t( + 'DBMS_CLOUD', 'DBMS_CLOUD_AI', 'DBMS_CLOUD_PIPELINE' + ); + FOR i in 1..v_packages.count LOOP + EXECUTE IMMEDIATE + 'REVOKE EXECUTE ON ' || v_packages(i) || ' FROM {0}'; + END LOOP; +END; +""" + +ENABLE_AI_PROFILE_DOMAIN_FOR_USER = """ +BEGIN + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE( + host => :host, + ace => xs$ace_type(privilege_list => xs$name_list('http'), + principal_name => :user, + principal_type => xs_acl.ptype_db) + ); +END; +""" + +DISABLE_AI_PROFILE_DOMAIN_FOR_USER = """ +BEGIN + DBMS_NETWORK_ACL_ADMIN.REMOVE_HOST_ACE( + host => :host, + ace => xs$ace_type(privilege_list => xs$name_list('http'), + principal_name => :user, + principal_type => xs_acl.ptype_db) + ); +END; +""" + +GET_USER_AI_PROFILE_ATTRIBUTES = """ +SELECT attribute_name, attribute_value +FROM USER_CLOUD_AI_PROFILE_ATTRIBUTES +WHERE profile_name = :profile_name +""" + +GET_USER_AI_PROFILE = """ +SELECT profile_name, description +FROM USER_CLOUD_AI_PROFILES +WHERE profile_name = :profile_name +""" + + +LIST_USER_AI_PROFILES = """ +SELECT profile_name, description +FROM USER_CLOUD_AI_PROFILES +WHERE REGEXP_LIKE(profile_name, :profile_name_pattern, 'i') +""" + +LIST_USER_VECTOR_INDEXES = """ +SELECT v.index_name, v.description +FROM USER_CLOUD_VECTOR_INDEXES v +WHERE REGEXP_LIKE(v.index_name, :index_name_pattern, 'i') +""" + +GET_USER_VECTOR_INDEX_ATTRIBUTES = """ +SELECT attribute_name, attribute_value +FROM USER_CLOUD_VECTOR_INDEX_ATTRIBUTES +WHERE INDEX_NAME = :index_name +""" + +LIST_USER_CONVERSATIONS = """ +SELECT conversation_id, + conversation_title, + description, + retention_days +from USER_CLOUD_AI_CONVERSATIONS +""" + +GET_USER_CONVERSATION_ATTRIBUTES = """ +SELECT conversation_title, + description, + retention_days +from USER_CLOUD_AI_CONVERSATIONS +WHERE conversation_id = :conversation_id +""" diff --git a/src/select_ai/synthetic_data.py b/src/select_ai/synthetic_data.py new file mode 100644 index 0000000..b378c88 --- /dev/null +++ b/src/select_ai/synthetic_data.py @@ -0,0 +1,84 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import json +from dataclasses import dataclass +from typing import List, Mapping, Optional + +from select_ai._abc import SelectAIDataClass + + +@dataclass +class SyntheticDataParams(SelectAIDataClass): + """Optional parameters to control generation of synthetic data + + :param int sample_rows: number of rows from the table to use as a sample + to guide the LLM in data generation + + :param bool table_statistics: Enable or disable the use of table + statistics information. Default value is False + + :param str priority: Assign a priority value that defines the number of + parallel requests sent to the LLM for generating synthetic data. + Tasks with a higher priority will consume more database resources and + complete faster. Possible values are: HIGH, MEDIUM, LOW + + :param bool comments: Enable or disable sending comments to the LLM to + guide data generation. Default value is False + + """ + + sample_rows: Optional[int] = None + table_statistics: Optional[bool] = False + priority: Optional[str] = "HIGH" + comments: Optional[bool] = False + + +@dataclass +class SyntheticDataAttributes(SelectAIDataClass): + """Attributes to control generation of synthetic data + + :param str object_name: Table name to populate synthetic data + :param List[Mapping] object_list: Use this to generate synthetic data + on multiple tables + :param str owner_name: Database user who owns the referenced object. + Default value is connected user's schema + :param int record_count: Number of records to generate + :param str user_prompt: User prompt to guide generation of synthetic data + For e.g. "the release date for the movies should be in 2019" + + """ + + object_name: Optional[str] = None + object_list: Optional[List[Mapping]] = None + owner_name: Optional[str] = None + params: Optional[SyntheticDataParams] = None + record_count: Optional[int] = None + user_prompt: Optional[str] = None + + def dict(self, exclude_null=True): + attributes = {} + for k, v in self.__dict__.items(): + if v is not None or not exclude_null: + if isinstance(v, SyntheticDataParams): + attributes[k] = v.json(exclude_null=exclude_null) + elif isinstance(v, List): + attributes[k] = json.dumps(v) + else: + attributes[k] = v + return attributes + + def prepare(self): + if self.object_name and self.object_list: + raise ValueError("Both object_name and object_list cannot be set") + + if not self.object_name and not self.object_list: + raise ValueError( + "One of object_name and object_list should be set" + ) + + return self.dict() diff --git a/src/select_ai/vector_index.py b/src/select_ai/vector_index.py new file mode 100644 index 0000000..c6078c4 --- /dev/null +++ b/src/select_ai/vector_index.py @@ -0,0 +1,546 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import json +from abc import ABC +from dataclasses import dataclass +from typing import AsyncGenerator, Iterator, Optional, Union + +import oracledb + +from select_ai import BaseProfile +from select_ai._abc import SelectAIDataClass +from select_ai._enums import StrEnum +from select_ai.async_profile import AsyncProfile +from select_ai.db import async_cursor, cursor +from select_ai.errors import VectorIndexNotFoundError +from select_ai.profile import Profile +from select_ai.sql import ( + GET_USER_VECTOR_INDEX_ATTRIBUTES, + LIST_USER_VECTOR_INDEXES, +) + + +class VectorDBProvider(StrEnum): + ORACLE = "oracle" + + +class VectorDistanceMetric(StrEnum): + EUCLIDEAN = "EUCLIDEAN" + L2_SQUARED = "L2_SQUARED" + COSINE = "COSINE" + DOT = "DOT" + MANHATTAN = "MANHATTAN" + HAMMING = "HAMMING" + + +@dataclass +class VectorIndexAttributes(SelectAIDataClass): + """ + Attributes of a vector index help to manage and configure the behavior of + the vector index. + + :param int chunk_size: Text size of chunking the input data. + :param int chunk_overlap: Specifies the amount of overlapping + characters between adjacent chunks of text. + :param str location: Location of the object store. + :param int match_limit: Specifies the maximum number of results to return + in a vector search query + :param str object_storage_credential_name: Name of the credentials for + accessing object storage. + :param str profile_name: Name of the AI profile which is used for + embedding source data and user prompts. + :param int refresh_rate: Interval of updating data in the vector store. + The unit is minutes. + :param float similarity_threshold: Defines the minimum level of similarity + required for two items to be considered a match + :param VectorDistanceMetric vector_distance_metric: Specifies the type of + distance calculation used to compare vectors in a database + :param VectorDBProvider vector_db_provider: Name of the Vector database + provider. Default value is "oracle" + :param str vector_db_endpoint: Endpoint to access the Vector database + :param str vector_db_credential_name: Name of the credentials for accessing + Vector database + :param int vector_dimension: Specifies the number of elements in each + vector within the vector store + :param str vector_table_name: Specifies the name of the table or collection + to store vector embeddings and chunked data + """ + + chunk_size: Optional[int] = 1024 + chunk_overlap: Optional[int] = 128 + location: Optional[str] = None + match_limit: Optional[int] = 5 + object_storage_credential_name: Optional[str] = None + profile_name: Optional[str] = None + refresh_rate: Optional[int] = 1440 + similarity_threshold: Optional[float] = 0 + vector_distance_metric: Optional[VectorDistanceMetric] = ( + VectorDistanceMetric.COSINE + ) + vector_db_endpoint: Optional[str] = None + vector_db_credential_name: Optional[str] = None + vector_db_provider: Optional[VectorDBProvider] = None + vector_dimension: Optional[int] = None + vector_table_name: Optional[str] = None + pipeline_name: Optional[str] = None + + def json(self, exclude_null=True): + attributes = self.dict(exclude_null=exclude_null) + attributes.pop("pipeline_name", None) + return json.dumps(attributes) + + @classmethod + def create(cls, *, vector_db_provider: Optional[str] = None, **kwargs): + for subclass in cls.__subclasses__(): + if subclass.vector_db_provider == vector_db_provider: + return subclass(**kwargs) + return cls(**kwargs) + + +@dataclass +class OracleVectorIndexAttributes(VectorIndexAttributes): + """Oracle specific vector index attributes""" + + vector_db_provider: Optional[VectorDBProvider] = VectorDBProvider.ORACLE + + +class _BaseVectorIndex(ABC): + + def __init__( + self, + profile: BaseProfile = None, + index_name: Optional[str] = None, + description: Optional[str] = None, + attributes: Optional[VectorIndexAttributes] = None, + ): + """Initialize a Vector Index""" + self.profile = profile + self.index_name = index_name + self.attributes = attributes + self.description = description + + def __repr__(self): + return ( + f"{self.__class__.__name__}(profile={self.profile}, " + f"index_name={self.index_name}, " + f"attributes={self.attributes}, description={self.description})" + ) + + +class VectorIndex(_BaseVectorIndex): + """ + VectorIndex objects let you manage vector indexes + + :param str index_name: The name of the vector index + :param str description: The description of the vector index + :param select_ai.VectorIndexAttributes attributes: The attributes of the vector index + """ + + @staticmethod + def _get_attributes(index_name: str) -> VectorIndexAttributes: + """Get attributes of a vector index + + :return: select_ai.VectorIndexAttributes + :raises: VectorIndexNotFoundError + """ + with cursor() as cr: + cr.execute(GET_USER_VECTOR_INDEX_ATTRIBUTES, index_name=index_name) + attributes = cr.fetchall() + if attributes: + post_processed_attributes = {} + for k, v in attributes: + if isinstance(v, oracledb.LOB): + post_processed_attributes[k] = v.read() + else: + post_processed_attributes[k] = v + return VectorIndexAttributes.create( + **post_processed_attributes + ) + else: + raise VectorIndexNotFoundError(index_name=index_name) + + def create(self, replace: Optional[bool] = False): + """Create a vector index in the database and populates the index + with data from an object store bucket using an async scheduler job + + :param bool replace: Replace vector index if it exists + :return: None + """ + + if self.attributes.profile_name is None: + self.attributes.profile_name = self.profile.profile_name + + parameters = { + "index_name": self.index_name, + "attributes": self.attributes.json(), + } + + if self.description: + parameters["description"] = self.description + + with cursor() as cr: + try: + cr.callproc( + "DBMS_CLOUD_AI.CREATE_VECTOR_INDEX", + keyword_parameters=parameters, + ) + except oracledb.DatabaseError as e: + (error,) = e.args + # If already exists and replace is True then drop and recreate + if "already exists" in error.message.lower() and replace: + self.delete(force=True) + cr.callproc( + "DBMS_CLOUD_AI.CREATE_VECTOR_INDEX", + keyword_parameters=parameters, + ) + else: + raise + self.profile.set_attribute("vector_index_name", self.index_name) + + def delete( + self, + include_data: Optional[bool] = True, + force: Optional[bool] = False, + ): + """This procedure removes a vector store index + + :param bool include_data: Indicates whether to delete + both the customer's vector store and vector index + along with the vector index object + :param bool force: Indicates whether to ignore errors + that occur if the vector index does not exist + :return: None + :raises: oracledb.DatabaseError + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.DROP_VECTOR_INDEX", + keyword_parameters={ + "index_name": self.index_name, + "include_data": include_data, + "force": force, + }, + ) + + def enable(self): + """This procedure enables or activates a previously disabled vector + index object. Generally, when you create a vector index, by default + it is enabled such that the AI profile can use it to perform indexing + and searching. + + :return: None + :raises: oracledb.DatabaseError + + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.ENABLE_VECTOR_INDEX", + keyword_parameters={"index_name": self.index_name}, + ) + + def disable(self): + """This procedure disables a vector index object in the current + database. When disabled, an AI profile cannot use the vector index, + and the system does not load data into the vector store as new data + is added to the object store and does not perform indexing, searching + or querying based on the index. + + :return: None + :raises: oracledb.DatabaseError + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.DISABLE_VECTOR_INDEX", + keyword_parameters={"index_name": self.index_name}, + ) + + def set_attributes( + self, + attribute_name: str, + attribute_value: Union[str, int, float], + attributes: VectorIndexAttributes = None, + ): + """ + This procedure updates an existing vector store index with a specified + value of the vector index attribute. You can specify a single attribute + or multiple attributes by passing an object of type + :class `VectorIndexAttributes` + + :param str attribute_name: Custom attribute name + :param Union[str, int, float] attribute_value: Attribute Value + :param VectorIndexAttributes attributes: Specify multiple attributes + to update in a single API invocation + :return: None + :raises: oracledb.DatabaseError + """ + if attribute_name and attribute_value and attributes: + raise ValueError( + "Either specify a single attribute using " + "attribute_name and attribute_value or " + "pass an object of type VectorIndexAttributes" + ) + + parameters = {"index_name": self.index_name} + if attributes: + parameters["attributes"] = attributes.json() + self.attributes = attributes + else: + setattr(self.attributes, attribute_name, attribute_value) + parameters["attributes_name"] = attribute_name + parameters["attributes_value"] = attribute_value + + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.UPDATE_VECTOR_INDEX", + keyword_parameters=parameters, + ) + + def get_attributes(self) -> VectorIndexAttributes: + """Get attributes of this vector index + + :return: select_ai.VectorIndexAttributes + :raises: VectorIndexNotFoundError + """ + return self._get_attributes(self.index_name) + + @classmethod + def list(cls, index_name_pattern: str = ".*") -> Iterator["VectorIndex"]: + """List Vector Indexes + + :param str index_name_pattern: Regular expressions can be used + to specify a pattern. Function REGEXP_LIKE is used to perform the + match. Default value is ".*" i.e. match all vector indexes. + + :return: Iterator[VectorIndex] + """ + with cursor() as cr: + cr.execute( + LIST_USER_VECTOR_INDEXES, + index_name_pattern=index_name_pattern, + ) + for row in cr.fetchall(): + index_name = row[0] + description = row[1].read() # Oracle.LOB + attributes = cls._get_attributes(index_name=index_name) + yield cls( + index_name=index_name, + description=description, + attributes=attributes, + profile=Profile(profile_name=attributes.profile_name), + ) + + +class AsyncVectorIndex(_BaseVectorIndex): + """ + AsyncVectorIndex objects let you manage vector indexes + using async APIs. Use this for non-blocking concurrent + requests + + :param str index_name: The name of the vector index + :param str description: The description of the vector index + :param VectorIndexAttributes attributes: The attributes of the vector index + """ + + @staticmethod + async def _get_attributes(index_name: str) -> VectorIndexAttributes: + """Get attributes of a vector index + + :return: select_ai.VectorIndexAttributes + :raises: VectorIndexNotFoundError + """ + async with async_cursor() as cr: + await cr.execute( + GET_USER_VECTOR_INDEX_ATTRIBUTES, index_name=index_name + ) + attributes = await cr.fetchall() + if attributes: + post_processed_attributes = {} + for k, v in attributes: + if isinstance(v, oracledb.AsyncLOB): + post_processed_attributes[k] = await v.read() + else: + post_processed_attributes[k] = v + return VectorIndexAttributes.create( + **post_processed_attributes + ) + else: + raise VectorIndexNotFoundError(index_name=index_name) + + async def create(self, replace: Optional[bool] = False) -> None: + """Create a vector index in the database and populates it with data + from an object store bucket using an async scheduler job + + :param bool replace: True to replace existing vector index + + """ + + if self.attributes.profile_name is None: + self.attributes.profile_name = self.profile.profile_name + parameters = { + "index_name": self.index_name, + "attributes": self.attributes.json(), + } + if self.description: + parameters["description"] = self.description + async with async_cursor() as cr: + try: + await cr.callproc( + "DBMS_CLOUD_AI.CREATE_VECTOR_INDEX", + keyword_parameters=parameters, + ) + except oracledb.DatabaseError as e: + (error,) = e.args + # If already exists and replace is True then drop and recreate + if "already exists" in error.message.lower() and replace: + await self.delete(force=True) + await cr.callproc( + "DBMS_CLOUD_AI.CREATE_VECTOR_INDEX", + keyword_parameters=parameters, + ) + else: + raise + + await self.profile.set_attribute("vector_index_name", self.index_name) + + async def delete( + self, + include_data: Optional[bool] = True, + force: Optional[bool] = False, + ) -> None: + """This procedure removes a vector store index. + + :param bool include_data: Indicates whether to delete + both the customer's vector store and vector index + along with the vector index object. + :param bool force: Indicates whether to ignore errors + that occur if the vector index does not exist. + :return: None + :raises: oracledb.DatabaseError + + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.DROP_VECTOR_INDEX", + keyword_parameters={ + "index_name": self.index_name, + "include_data": include_data, + "force": force, + }, + ) + + async def enable(self) -> None: + """This procedure enables or activates a previously disabled vector + index object. Generally, when you create a vector index, by default + it is enabled such that the AI profile can use it to perform indexing + and searching. + + :return: None + :raises: oracledb.DatabaseError + + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.ENABLE_VECTOR_INDEX", + keyword_parameters={"index_name": self.index_name}, + ) + + async def disable(self) -> None: + """This procedure disables a vector index object in the current + database. When disabled, an AI profile cannot use the vector index, + and the system does not load data into the vector store as new data + is added to the object store and does not perform indexing, searching + or querying based on the index. + + :return: None + :raises: oracledb.DatabaseError + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.DISABLE_VECTOR_INDEX", + keyword_parameters={"index_name": self.index_name}, + ) + + async def set_attributes( + self, + attribute_name: str, + attribute_value: Union[str, int], + attributes: VectorIndexAttributes = None, + ) -> None: + """ + This procedure updates an existing vector store index with a specified + value of the vector index attribute. You can specify a single attribute + or multiple attributes by passing an object of type + :class `VectorIndexAttributes` + + :param str attribute_name: Custom attribute name + :param Union[str, int, float] attribute_value: Attribute Value + :param VectorIndexAttributes attributes: Specify multiple attributes + to update in a single API invocation + :return: None + :raises: oracledb.DatabaseError + """ + if attribute_name and attribute_value and attributes: + raise ValueError( + "Either specify a single attribute using " + "attribute_name and attribute_value or " + "pass an object of type VectorIndexAttributes" + ) + parameters = {"index_name": self.index_name} + if attributes: + self.attributes = attributes + parameters["attributes"] = attributes.json() + else: + setattr(self.attributes, attribute_name, attribute_value) + parameters["attributes_name"] = attribute_name + parameters["attributes_value"] = attribute_value + + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.UPDATE_VECTOR_INDEX", + keyword_parameters=parameters, + ) + + async def get_attributes(self) -> VectorIndexAttributes: + """Get attributes of a vector index + + :return: select_ai.VectorIndexAttributes + :raises: VectorIndexNotFoundError + """ + return await self._get_attributes(index_name=self.index_name) + + @classmethod + async def list( + cls, index_name_pattern: str = ".*" + ) -> AsyncGenerator[VectorIndex, None]: + """List Vector Indexes. + + :param str index_name_pattern: Regular expressions can be used + to specify a pattern. Function REGEXP_LIKE is used to perform the + match. Default value is ".*" i.e. match all vector indexes. + + :return: AsyncGenerator[VectorIndex] + + """ + async with async_cursor() as cr: + await cr.execute( + LIST_USER_VECTOR_INDEXES, + index_name_pattern=index_name_pattern, + ) + rows = await cr.fetchall() + for row in rows: + index_name = row[0] + description = await row[1].read() # AsyncLOB + attributes = await cls._get_attributes(index_name=index_name) + yield VectorIndex( + index_name=index_name, + description=description, + attributes=attributes, + profile=await AsyncProfile( + profile_name=attributes.profile_name + ), + ) diff --git a/src/select_ai/version.py b/src/select_ai/version.py new file mode 100644 index 0000000..bfcce91 --- /dev/null +++ b/src/select_ai/version.py @@ -0,0 +1,8 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +__version__ = "1.0.0.dev7"