diff --git a/askme/.streamlit/config.toml b/askme/.streamlit/config.toml new file mode 100644 index 0000000..8829cec --- /dev/null +++ b/askme/.streamlit/config.toml @@ -0,0 +1,7 @@ +[client] +showErrorDetails = false +showSidebarNavigation = false +toolbarMode = "viewer" + +[browser] +gatherUsageStats = false \ No newline at end of file diff --git a/askme/README.md b/askme/README.md new file mode 100644 index 0000000..7de5cae --- /dev/null +++ b/askme/README.md @@ -0,0 +1,87 @@ +[![License: UPL](https://img.shields.io/badge/license-UPL-green)](https://img.shields.io/badge/license-UPL-green) [![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=oracle-devrel_tech-content-heatwave)](https://sonarcloud.io/dashboard?id=oracle-devrel_tech-content-heatwave) + +# HeatWave GenAI Apps: AskME +AskME is a suite of features designed to empower users to maximize the potential of their data utilizing cutting-edge artificial intelligence capabilities offered by HeatWave GenAI. This comprehensive solution offers the following functionalities: + +1. Find Relevant Documents: Users can provide a prompt, and AskME will retrieve and present relevant documents in a user-friendly manner. + +2. Free-Style Answer Generation: Users can ask questions, and AskME will include relevant information from the knowledge base to answer the question. + +3. Summarized Answer Generation: Users can ask questions, and AskME will summarize relevant information from the knowledge base which is related to the question. + +4. Chatbot Functionality: AskME allows users to ask follow-up questions and review their chat history. + +5. Knowledge Base Management: Users can create and delete the vector tables. + +## Prerequisite + +You must have an OCI account. [Click here](https://docs.oracle.com/en/cloud/paas/content-cloud/administer/create-and-activate-oracle-cloud-account.html) for more information about Oracle Cloud account creation and activation. Free-tier accounts are currently not supported for the deployment of AskME resources. + +There are required OCI resources (see the [Terraform documentation](./terraform/README.md) for more information) that are needed for this tutorial. + +## Getting Started: answer questions using your documents + +### Create a vector table + +In the AskME app, it is easy to use your documents to create a new vector table. + +
+ AskME Knowledge Base interface +
+ +From the `Knowledge Base Management` tab (${\textsf{\color{red}1}}$), under `Create Vector Table` (${\textsf{\color{red}2}}$), you can choose to upload one or more documents to AskME (${\textsf{\color{red}3}}$): +- Here is a sample document you can download and use for that purpose: [Onboarding Checklist for New Hires at Nexus Innovations.pdf](./assets/Onboarding%20Checklist%20for%20New%20Hires%20at%20Nexus%20Innovations.pdf)
+- Feel free to use your own documents to try the vector table creation. + +After having browsed and selected the relevant files, you can change the vector table name if the default one does not match your needs (${\textsf{\color{red}4}}$).
+This name will be used to identify the vector table, and may correspond to the common denominator of all selected files. + +Then click on the `Upload` button to start the vector table creation process (${\textsf{\color{red}5}}$).
+For the sample document provided earlier, this process should take 30-60 seconds. It may take several minutes if multiple files/bigger files are selected. + +Once the vector table has been created, a green message is displayed, explaining that the table creation was successful, and the new table name should be added to the `Selected Vector Tables` list (${\textsf{\color{red}6}}$). +
+ +### Use the vector table to answer questions + +Any existing vector table can be used in AskME to generate an accurate answer, with references to the original documents. + +
+ AskME free-style answer +
+ +From the `Free-style Answer` tab (${\textsf{\color{red}1}}$), you can enter your question in the input field (${\textsf{\color{red}3}}$).
+Here, we choose to ask a question related to the sample document uploaded in the previous section: *What are the onboarding steps for new hires at Nexus Innovation?* + +Please make sure that all vector tables needed for the question are selected in the Knowledge Base Selection area (${\textsf{\color{red}2}}$), and then you can click on the `Answer Question` button (${\textsf{\color{red}4}}$). + +The answer should appear under the `Answer Question` button after a few seconds, followed by a clickable list of document references that have been used to generate the answer. + +## Contributing + +This project is open source. Please submit your contributions by forking this repository and submitting a pull request! Oracle appreciates any contributions that are made by the open source community. + +## Acknowledgments + +- [Oracle Cloud Infrastructure (OCI)](https://www.oracle.com/cloud/) +- [Streamlit Documentation](https://docs.streamlit.io/) + +## Security + +Please consult the [security guide](./SECURITY.md) for our responsible security +vulnerability disclosure process. + +## License + +Copyright (c) 2025 Oracle and/or its affiliates. + +Licensed under the Universal Permissive License (UPL), Version 1.0. + +See [LICENSE](LICENSE) for more details. + +For third party licenses, see [THIRD_PARTY_LICENSES](licenses/THIRD_PARTY_LICENSES.txt). + +For HeatWave User Guide legal information, see the [Legal Notices] (https://dev.mysql.com/doc/heatwave/en/preface.html#legalnotice). + +ORACLE AND ITS AFFILIATES DO NOT PROVIDE ANY WARRANTY WHATSOEVER, EXPRESS OR IMPLIED, FOR ANY SOFTWARE, MATERIAL OR CONTENT OF ANY KIND CONTAINED OR PRODUCED WITHIN THIS REPOSITORY, AND IN PARTICULAR SPECIFICALLY DISCLAIM ANY AND ALL IMPLIED WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, AND FITNESS FOR A PARTICULAR PURPOSE. FURTHERMORE, ORACLE AND ITS AFFILIATES DO NOT REPRESENT THAT ANY CUSTOMARY SECURITY REVIEW HAS BEEN PERFORMED WITH RESPECT TO ANY SOFTWARE, MATERIAL OR CONTENT CONTAINED OR PRODUCED WITHIN THIS REPOSITORY. IN ADDITION, AND WITHOUT LIMITING THE FOREGOING, THIRD PARTIES MAY HAVE POSTED SOFTWARE, MATERIAL OR CONTENT TO THIS REPOSITORY WITHOUT ANY REVIEW. USE AT YOUR OWN RISK. + diff --git a/askme/SECURITY.md b/askme/SECURITY.md new file mode 100644 index 0000000..fb42c94 --- /dev/null +++ b/askme/SECURITY.md @@ -0,0 +1,38 @@ +# Reporting security vulnerabilities + +Oracle values the independent security research community and believes that +responsible disclosure of security vulnerabilities helps us ensure the security +and privacy of all our users. + +Please do NOT raise a GitHub Issue to report a security vulnerability. If you +believe you have found a security vulnerability, please submit a report to +[secalert_us@oracle.com][1] preferably with a proof of concept. Please review +some additional information on [how to report security vulnerabilities to Oracle][2]. +We encourage people who contact Oracle Security to use email encryption using +[our encryption key][3]. + +We ask that you do not use other channels or contact the project maintainers +directly. + +Non-vulnerability related security issues including ideas for new or improved +security features are welcome on GitHub Issues. + +## Security updates, alerts and bulletins + +Security updates will be released on a regular cadence. Many of our projects +will typically release security fixes in conjunction with the +Oracle Critical Patch Update program. Additional +information, including past advisories, is available on our [security alerts][4] +page. + +## Security-related information + +We will provide security related information such as a threat model, considerations +for secure use, or any known security issues in our documentation. Please note +that labs and sample code are intended to demonstrate a concept and may not be +sufficiently hardened for production use. + +[1]: mailto:secalert_us@oracle.com +[2]: https://www.oracle.com/corporate/security-practices/assurance/vulnerability/reporting.html +[3]: https://www.oracle.com/security-alerts/encryptionkey.html +[4]: https://www.oracle.com/security-alerts/ \ No newline at end of file diff --git a/askme/askme.py b/askme/askme.py new file mode 100644 index 0000000..cd90bdc --- /dev/null +++ b/askme/askme.py @@ -0,0 +1,430 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +import streamlit as st +import os +from datetime import datetime +from utils.genai_helper import (get_table_list, create_vector_store, get_connection, + chatbot_interaction, get_chat_history_for_current_session, + filename_to_mysql_table_name, get_llm_list, upload_files_oci, + search_similar_chunks, group_relevant_chunks_by_url, + askme_generate_answer, question_based_KB_summarization, + cleanup_vector_table_materials) +from constants import (DEFAULT_EMPTY_VECTOR_TABLE_NAME, HEATWAVE_MANUALS_VECTOR_TABLE_NAME, + DEFAULT_ASKME_SCHEMA_NAME, DEFAULT_USER_UPLOAD_BUCKET_DIR, + DEFAULT_USER_DATA_PREFIX, FIND_DOC_MAX_CHUNK_TOPK, + ANSWER_SUMMARY_MAX_CHUNK_TOPK, ANSWER_SUMMARY_MIN_SIMILARITY_SCORE, + ANSWER_MAX_CHUNK_TOPK, RETRIEVAL_NUM_CHUNK_AFTER, + RETRIEVAL_NUM_CHUNKS_BEFORE, ML_RAG_SEGMENT_OVERLAP, DEFAULT_LLM_MODEL) +from utils.exceptions import AskMEException, BackendConnectionException +from utils.util import setup_logging +logger = setup_logging() + +BUCKET_DIR_PREFIX = os.path.join(DEFAULT_USER_UPLOAD_BUCKET_DIR, DEFAULT_USER_DATA_PREFIX) +SCHEMA_NAME = DEFAULT_ASKME_SCHEMA_NAME + +# Show a banner if a backend function raised an exception +def st_handle_backend_exception_banner(return_value = None): + def decorator(func): + def wrapper_func(*args, **kwargs): + try: + return func(*args, **kwargs) + except BackendConnectionException: + st.warning("Generative AI service communication issue, please try again later.") + except AskMEException: + st.warning("Generative AI error, please contact the application support.") + except (Exception): + st.warning("AskME service error, please contact the application support.") + logger.exception("AskME service error, please contact the application support.") + return return_value + return wrapper_func + return decorator + +def initialize_session_states(): + # Initialize general session states + if "askme_selected_kb" not in st.session_state: + st.session_state.askme_selected_kb = [] + if "askme_knowledge" not in st.session_state: + st.session_state.askme_knowledge = {} + st.session_state.askme_knowledge[SCHEMA_NAME] = get_table_list(SCHEMA_NAME) + if "askme_supported_llm_models" not in st.session_state: + st.session_state.askme_supported_llm_models = get_llm_list(SCHEMA_NAME) + if "askme_selected_llm_model" not in st.session_state: + st.session_state.askme_selected_llm_model = DEFAULT_LLM_MODEL if DEFAULT_LLM_MODEL in st.session_state.askme_supported_llm_models else st.session_state.askme_supported_llm_models[0] + + + # Initialize chatbot session states + if "askme_chatbot_show_upload_form" not in st.session_state: + st.session_state.askme_chatbot_show_upload_form = False + if "askme_chatbot_db_connection" not in st.session_state: + st.session_state.askme_chatbot_db_connection = get_connection(SCHEMA_NAME) + if "askme_chatbot_chat_history" not in st.session_state: + st.session_state.askme_chatbot_chat_history = [] + if "askme_chatbot_uploader_key" not in st.session_state: + st.session_state.askme_chatbot_uploader_key = "chatbot_kb_creation_uploader_key" + + # Initialize KB management tab states + if "askme_main_uploader_key" not in st.session_state: + st.session_state.askme_main_uploader_key = "main_kb_creation_uploader_key" + + # Initialize Find relevant doc session states + if "askme_relevant_doc_db_connection" not in st.session_state: + st.session_state.askme_relevant_doc_db_connection = get_connection(SCHEMA_NAME) + if "askme_relevant_doc_min_similarity_score" not in st.session_state: + st.session_state.askme_relevant_doc_min_similarity_score = 0.4 + if "askme_relevant_doc_topk" not in st.session_state: + st.session_state.askme_relevant_doc_topk = 20 + + # Initialize AskME session states + if "askme_plain_answer_db_connection" not in st.session_state: + st.session_state.askme_plain_answer_db_connection = get_connection(SCHEMA_NAME) + + # Initialize Answer Summary session states + if "askme_answer_summary_db_connection" not in st.session_state: + st.session_state.askme_answer_summary_db_connection = get_connection(SCHEMA_NAME) + +@st_handle_backend_exception_banner() +def update_table_list(): + st.session_state.askme_knowledge[SCHEMA_NAME] = get_table_list(SCHEMA_NAME) + +def create_sidebar(): + with st.sidebar: + st.image("assets/hw.png", width=500) + st.write() + +def render_sidebar(): + with st.sidebar: + st.title("**Knowledge Base Selection**") + new_index_list = [] + for index in st.session_state.askme_selected_kb: + if index in st.session_state.askme_knowledge[SCHEMA_NAME]: + new_index_list.append(index) + st.session_state.askme_selected_kb = new_index_list + + askme_selected_kb = st.multiselect( + "Select Vector Tables", + options=st.session_state.askme_knowledge[SCHEMA_NAME], + default=st.session_state.askme_selected_kb, + placeholder="Select a vector table", + ) + + # Update session state whenever selection changes + if askme_selected_kb != st.session_state.askme_selected_kb: + st.session_state.askme_selected_kb = askme_selected_kb + st.rerun() + + st.write() + st.write("---") + st.title("**Settings**") + with st.expander("Settings"): + selected = st.selectbox( + "LLM Models", + st.session_state.askme_supported_llm_models, + index=st.session_state.askme_supported_llm_models.index(st.session_state.askme_selected_llm_model) + ) + st.session_state.askme_selected_llm_model = selected + +@st_handle_backend_exception_banner() +def create_chatbot_dashboard(): + st.subheader("Heatwave Chatbot") + # render the chat history + with st.container(): + if len(st.session_state.askme_chatbot_chat_history)==0: + st.write("I am AskME, ready to answer your questions based on your own data. How can I help you?") + st.write("---") + for item in st.session_state.askme_chatbot_chat_history: + conversation = item[0] + citations = item[1] + st.markdown(f""" +
+ 🗣️ {conversation['user_message']} +
+ """, unsafe_allow_html=True) + st.markdown(f""" +
+ 🤖 {conversation['chat_bot_message']} +
+ """, unsafe_allow_html=True) + if citations: + distinct_urls = {item["id"] for item in citations} + with st.expander("Citations", expanded=False): + for url in distinct_urls: + st.write("---") + st.write(f"**Link:** {url}") + st.write("---") + col1, col2 = st.columns([3, 50]) + with col1: + if st.button("+", key="chatbot_upload_button"): + st.session_state.askme_chatbot_show_upload_form = True + with col2: + question = st.chat_input("Ask a question") + + if st.session_state.askme_chatbot_show_upload_form: + render_vector_store_creation_chatbot() + + if question: + response = "" + with st.spinner('Answering the question...'): + response, citations = chatbot_interaction(st.session_state.askme_chatbot_db_connection, question, SCHEMA_NAME, st.session_state.askme_selected_kb, st.session_state.askme_selected_llm_model, st.session_state.askme_supported_llm_models) + st.session_state.askme_chatbot_chat_history.append ([get_chat_history_for_current_session(st.session_state.askme_chatbot_db_connection)[-1], citations]) + st.rerun() + +def close_chatbot_kb_management_status(): + st.session_state.askme_chatbot_show_upload_form = False + st.session_state.askme_chatbot_uploader_key = f"askme_chatbot_uploader_key_{datetime.now().timestamp()}" + +@st_handle_backend_exception_banner() +def render_vector_store_creation_chatbot(): + with st.expander(f"Create a new vector table using your own files.", expanded=True): + uploaded_files = st.file_uploader("Choose a file or folder", accept_multiple_files=True, key=st.session_state.askme_chatbot_uploader_key, type=['pdf', 'doc', 'docx', 'txt', 'html', 'ppt', 'pptx']) + with st.form(f"chatbot_kb_form", clear_on_submit=False, border=False): + default_index_name = "" + if uploaded_files: + if len(uploaded_files) == 1: + default_index_name, _ = os.path.splitext(uploaded_files[0].name) + else: + default_index_name = "t_" + datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + default_index_name = filename_to_mysql_table_name(default_index_name) + + col1, col2, col3 = st.columns([3,1,1]) + with col1: + vector_table_name = st.text_input("Vector Table Name", value=default_index_name, key=f"chatbot_table_name", placeholder="Enter the vector table name..." , max_chars=20) + with col2: + st.markdown("
", unsafe_allow_html=True) + submit_button = st.form_submit_button("Upload") + with col3: + st.markdown("
", unsafe_allow_html=True) + st.form_submit_button("Close", on_click=close_chatbot_kb_management_status) + + if submit_button: + if not uploaded_files: + st.warning("Please choose some file(s) to upload.") + elif len(vector_table_name) == 0: + st.warning("Invalid vector table name.") + elif vector_table_name in st.session_state.askme_knowledge[SCHEMA_NAME]: + st.warning("This name is already used, please provide another vector table name.") + else: + with st.spinner('Creating vector table...'): + bucket_dir_name = f"{BUCKET_DIR_PREFIX}{vector_table_name}" + upload_files_oci(uploaded_files, bucket_dir_name) + vector_creation_output = create_vector_store(SCHEMA_NAME, vector_table_name, bucket_dir_name) + update_table_list() + # the index creation query is run and no error is reported + if vector_table_name in st.session_state.askme_knowledge[SCHEMA_NAME]: + st.session_state.askme_selected_kb.append(f"{vector_table_name}") + st.success(f"Vector table created, you can now ask your question.") + logger.info(f"Vector table created, you can now ask your question.") + else: + separator = "\n\n" + warning_message = "Something Went Wrong." + (f" Error:{separator}{separator.join(vector_creation_output)}" + if vector_creation_output else "") + st.warning(warning_message) + logger.warning(warning_message) + cleanup_vector_table_materials(SCHEMA_NAME, [vector_table_name], BUCKET_DIR_PREFIX) + +def clear_main_kb_management_status(): + st.session_state.askme_main_uploader_key = f"askme_main_uploader_key_{datetime.now().timestamp()}" + +@st_handle_backend_exception_banner() +def render_vector_store_management_main(): + tab1, tab2, tab3 = st.tabs(["Create Vector Table","Delete Vector Table", "Reset Knowledge Base"]) + with tab1: + uploaded_files = st.file_uploader("Choose the file(s) to upload", accept_multiple_files=True, key=st.session_state.askme_main_uploader_key, type=['pdf', 'doc', 'docx', 'txt', 'html', 'ppt', 'pptx']) + with st.form(f"main_kb_form", clear_on_submit=False, border=False): + default_index_name = "" + if uploaded_files: + if len(uploaded_files) == 1: + default_index_name, _ = os.path.splitext(uploaded_files[0].name) + else: + default_index_name = "t_" + datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + default_index_name = filename_to_mysql_table_name(default_index_name) + + vector_table_name = st.text_input("Vector Table Name", value=default_index_name, key=f"main_table_name", placeholder="Enter the vector table name...", max_chars=20) + col1, col2 = st.columns([1,1]) + with col1: + st.markdown("
", unsafe_allow_html=True) + submit_button = st.form_submit_button("Upload") + with col2: + st.markdown("
", unsafe_allow_html=True) + st.form_submit_button("Cancel", on_click=clear_main_kb_management_status) + + if submit_button: + if not uploaded_files: + st.warning("Please choose some file(s) to upload.") + elif len(vector_table_name) == 0: + st.warning("Invalid vector table name.") + elif vector_table_name in st.session_state.askme_knowledge[SCHEMA_NAME]: + st.warning("This name is already used, please provide another vector table name.") + else: + with st.spinner('Creating vector table...'): + bucket_dir_name = f"{BUCKET_DIR_PREFIX}{vector_table_name}" + upload_files_oci(uploaded_files, bucket_dir_name) + vector_creation_output = create_vector_store(SCHEMA_NAME, vector_table_name, bucket_dir_name) + update_table_list() + # the index creation query is run and no error is reported + if vector_table_name in st.session_state.askme_knowledge[SCHEMA_NAME]: + st.session_state.askme_selected_kb.append(f"{vector_table_name}") + st.success(f"Vector table {vector_table_name} was created successfully.") + logger.info(f"Vector table {SCHEMA_NAME}.{vector_table_name} was created successfully.") + else: + separator = "\n\n" + warning_message = "Something Went Wrong." + (f" Error:{separator}{separator.join(vector_creation_output)}" + if vector_creation_output else "") + st.warning(warning_message) + logger.warning(warning_message) + cleanup_vector_table_materials(SCHEMA_NAME, [vector_table_name], BUCKET_DIR_PREFIX) + + with tab2: + st.warning(f"Warning: this operation removes the vector table and its corresponding objects in the object store.") + update_table_list() + with st.form(f"main_kb_delete_form", clear_on_submit=False, border=False): + # Prevent the user from removing the default vector tables from AskME + delete_selectbox_options = None + if st.session_state.askme_knowledge[SCHEMA_NAME]: + delete_selectbox_options = [table_name for table_name in st.session_state.askme_knowledge[SCHEMA_NAME] + if table_name not in [DEFAULT_EMPTY_VECTOR_TABLE_NAME, + HEATWAVE_MANUALS_VECTOR_TABLE_NAME]] + table = st.selectbox( + "**Available Vector Tables**", + options=delete_selectbox_options, + index=0 if delete_selectbox_options else None, + placeholder="No vector table was found..." + ) + submit_button = st.form_submit_button("Delete Vector Table") + + if submit_button: + cleanup_vector_table_materials(SCHEMA_NAME, [table], BUCKET_DIR_PREFIX) + update_table_list() + if not table in st.session_state.askme_knowledge[SCHEMA_NAME]: + st.success(f"Deleted successfully.") + logger.info(f"Vector table {SCHEMA_NAME}.{table} was deleted successfully.") + else: + st.warning(f"Deletion failed.") + logger.info(f"Problem with vector table {SCHEMA_NAME}.{table} deletion.") + + with tab3: + st.warning(f"Warning: this operation removes all vector tables and their corresponding objects in the object store.") + col1, _ = st.columns([2,4]) + deletion_flag = False + with col1: + if st.button("Reset Knowledge Base"): + cleanup_vector_table_materials(SCHEMA_NAME, st.session_state.askme_knowledge[SCHEMA_NAME], BUCKET_DIR_PREFIX) + update_table_list() + deletion_flag = True + if deletion_flag: + st.success(f"The knowledge base has been reset.") + +def render_citations(relevant_chunks): + st.write("---") + st.markdown("
References
", unsafe_allow_html=True) + relevant_docs = group_relevant_chunks_by_url(relevant_chunks) + if relevant_docs: + for idx, doc in enumerate(relevant_docs): + if len(doc['url']) > 0: + url = doc['url'] + chunks = doc['chunks'] + title = chunks[0]['title'] + max_similarity_score = max(chunk['similarity_score'] for chunk in chunks) + with st.expander(f"{title}"): + st.subheader(f"**Max Similarity Score:** {max_similarity_score:.2f}") + st.write(f"**Link:** {url} ") + for i, chunk in enumerate(chunks): + st.write(f"**Segment {i+1}** with Similarity Score of: {chunk['similarity_score']:.2f}" ) + st.code( f"{chunk['content_chunk']}", height=100) + st.write("---") + else: + st.warning("No relevant document was found.") + +@st_handle_backend_exception_banner() +def find_relevant_docs(prompt): + relevant_doc_chunks_result = search_similar_chunks(st.session_state.askme_relevant_doc_db_connection, prompt, SCHEMA_NAME, st.session_state.askme_selected_kb, FIND_DOC_MAX_CHUNK_TOPK, RETRIEVAL_NUM_CHUNKS_BEFORE, RETRIEVAL_NUM_CHUNK_AFTER, st.session_state.askme_relevant_doc_min_similarity_score) + render_citations(relevant_doc_chunks_result[:st.session_state.askme_relevant_doc_topk]) + +@st_handle_backend_exception_banner() +def askme_answer(prompt): + response, citations = askme_generate_answer(st.session_state.askme_plain_answer_db_connection, prompt, st.session_state.askme_selected_llm_model, st.session_state.askme_supported_llm_models, SCHEMA_NAME, st.session_state.askme_selected_kb , ANSWER_MAX_CHUNK_TOPK, ML_RAG_SEGMENT_OVERLAP) + st.write(response) + render_citations(citations) + +@st_handle_backend_exception_banner() +def askme_answer_summary(prompt): + relevant_doc_chunks_result = search_similar_chunks(st.session_state.askme_answer_summary_db_connection, prompt, SCHEMA_NAME, st.session_state.askme_selected_kb, ANSWER_SUMMARY_MAX_CHUNK_TOPK, RETRIEVAL_NUM_CHUNKS_BEFORE, RETRIEVAL_NUM_CHUNK_AFTER, ANSWER_SUMMARY_MIN_SIMILARITY_SCORE) + summary = question_based_KB_summarization(st.session_state.askme_answer_summary_db_connection, prompt, relevant_doc_chunks_result, st.session_state.askme_selected_llm_model, st.session_state.askme_supported_llm_models) + st.write(summary) + render_citations(relevant_doc_chunks_result) + +def create_relevant_docs_dashboard(): + st.subheader("Find Relevant Documents") + with st.form(f"relevant_docs_form", clear_on_submit=False, border=False): + with st.expander("Search Parameters"): + col1, col2 = st.columns([1,1.5]) + with col1: + st.session_state.askme_relevant_doc_min_similarity_score = st.slider("Minimum Similarity Score (between 0 and 1)", + min_value=0.0, + max_value=1.0, + value=st.session_state.askme_relevant_doc_min_similarity_score, + step=0.01) + with col2: + st.session_state.askme_relevant_doc_topk = st.slider("Maximum Number of Doc Recommendations (between 0 and 100)", + min_value=1, + max_value=100, + value=st.session_state.askme_relevant_doc_topk, + step=1) + prompt = st.text_area("Enter your question:", key="relevant_docs_question", height=150) + submit_button = st.form_submit_button("Find Relevant Document") + if submit_button: + if prompt.strip(): + find_relevant_docs(prompt) + else: + st.warning("Please enter a prompt first.") + +def create_askme_dashboard(): + st.subheader("Free-style Answer Generation") + with st.form(f"free_style_answer_form", clear_on_submit=False, border=False): + prompt = st.text_area("Enter your question:", key="askme_question", height=150) + submit_button = st.form_submit_button("Answer Question") + if submit_button: + if prompt.strip(): + askme_answer(prompt) + else: + st.warning("Please enter a prompt before submitting!") + +def create_custom_askme_dashboard(): + st.subheader("Custom AskME") + +def create_answer_summary_dashboard(): + st.subheader("Summarize Docs to Answer Questions") + with st.form(f"answer_summary_form", clear_on_submit=False, border=False): + prompt = st.text_area("Enter your question:", key="askme_summarize_question", height=150) + submit_button = st.form_submit_button("Summarized Answer Generation") + if submit_button: + if prompt.strip(): + askme_answer_summary(prompt) + else: + st.warning("Please enter a prompt before submitting!") + +def create_summarize_dashboard(): + st.subheader("Summarize Documents") + +if __name__ == "__main__": + with open('styles/style.css') as f: + styles = f.read() + st.markdown(f"", unsafe_allow_html=True) + + st.markdown("
HeatWave GenAI Apps
", unsafe_allow_html=True) + st.write() + + create_sidebar() + initialize_session_states() + tab1, tab2, tab3, tab4, tab5 = st.tabs(["Find Relevant Docs", "Free-style Answer", "Answer Summary", "Chatbot", "$\qquad$Knowledge Base Management"]) + with tab1: + create_relevant_docs_dashboard() + with tab2: + create_askme_dashboard() + with tab3: + create_answer_summary_dashboard() + with tab4: + create_chatbot_dashboard() + with tab5: + render_vector_store_management_main() + render_sidebar() \ No newline at end of file diff --git a/askme/assets/Onboarding Checklist for New Hires at Nexus Innovations.pdf b/askme/assets/Onboarding Checklist for New Hires at Nexus Innovations.pdf new file mode 100644 index 0000000..6e61174 Binary files /dev/null and b/askme/assets/Onboarding Checklist for New Hires at Nexus Innovations.pdf differ diff --git a/askme/assets/askme_answer_interface.png b/askme/assets/askme_answer_interface.png new file mode 100644 index 0000000..1d61e04 Binary files /dev/null and b/askme/assets/askme_answer_interface.png differ diff --git a/askme/assets/askme_kb_interface.png b/askme/assets/askme_kb_interface.png new file mode 100644 index 0000000..9bda414 Binary files /dev/null and b/askme/assets/askme_kb_interface.png differ diff --git a/askme/assets/askme_kb_interface_uploaded.png b/askme/assets/askme_kb_interface_uploaded.png new file mode 100644 index 0000000..732e0eb Binary files /dev/null and b/askme/assets/askme_kb_interface_uploaded.png differ diff --git a/askme/assets/hw.png b/askme/assets/hw.png new file mode 100644 index 0000000..f317e01 Binary files /dev/null and b/askme/assets/hw.png differ diff --git a/askme/constants.py b/askme/constants.py new file mode 100644 index 0000000..dbd05bd --- /dev/null +++ b/askme/constants.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +# Default documentation ingested in AskME during database setup +DEFAULT_ASKME_SCHEMA_NAME = "askme" +HEATWAVE_MANUALS_BUCKET_DIR = "documentation" +HEATWAVE_MANUALS_VECTOR_TABLE_NAME = "heatwave_manuals" + +DEFAULT_EMPTY_BUCKET_DIR = "workaround_empty" +DEFAULT_EMPTY_VECTOR_TABLE_NAME = "workaround_empty_pdf" + +DEFAULT_USER_UPLOAD_BUCKET_DIR = "askme_user_data" +DEFAULT_USER_DATA_PREFIX = "user_documents-" + +FIND_DOC_MAX_CHUNK_TOPK = 200 +ANSWER_SUMMARY_MAX_CHUNK_TOPK = 5 +ANSWER_SUMMARY_MIN_SIMILARITY_SCORE = 0.0 +ANSWER_MAX_CHUNK_TOPK = 3 +RETRIEVAL_NUM_CHUNKS_BEFORE = 0 +RETRIEVAL_NUM_CHUNK_AFTER = 1 +SUMMARY_MAX_PROMPT_SIZE = 12000 # characters +ML_RAG_SEGMENT_OVERLAP = 2 +DEFAULT_LLM_MODEL = "meta.llama-3.3-70b-instruct" +ANSWER_SUMMARY_PROMPT = """ + You are a data summarizer. I will provide you with a question and relevant context data. Your task is to summarize the parts of the context that are most relevant to answering the question. + + Context: + {context} + + Question: + {question} + + Please provide a concise summary based on the question. +""" + + +# OCI helper constants + +CLIENT_TIMEOUT = (10,240) +FREEFORM_TAG_KEY = "demo" +FREEFORM_TAG_VALUE = "askme" +ADDITIONAL_FIELDS_LIST = ["tags"] \ No newline at end of file diff --git a/askme/licenses/THIRD_PARTY_LICENSES.txt b/askme/licenses/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..a22fe1b --- /dev/null +++ b/askme/licenses/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,186 @@ +LICENSES FOR THIRD-PARTY COMPONENTS +============================================================================== + +The following sections contain licensing information for libraries that we have +included with the sample source and components used to. We are thankful to all +individuals that have created these. + +=============================================================================== + + License: Apache License v2.0 + + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/askme/requirements.txt b/askme/requirements.txt new file mode 100644 index 0000000..9581e88 --- /dev/null +++ b/askme/requirements.txt @@ -0,0 +1,3 @@ +mysql-connector-python==8.2.0 +oci==2.146.0 +streamlit==1.42.2 diff --git a/askme/setup.py b/askme/setup.py new file mode 100644 index 0000000..012c281 --- /dev/null +++ b/askme/setup.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +from utils.genai_helper import create_vector_store +from constants import DEFAULT_ASKME_SCHEMA_NAME, HEATWAVE_MANUALS_VECTOR_TABLE_NAME, HEATWAVE_MANUALS_BUCKET_DIR, DEFAULT_EMPTY_VECTOR_TABLE_NAME, DEFAULT_EMPTY_BUCKET_DIR +from utils.util import setup_logging +logger = setup_logging() + +rows = create_vector_store(DEFAULT_ASKME_SCHEMA_NAME, DEFAULT_EMPTY_VECTOR_TABLE_NAME, DEFAULT_EMPTY_BUCKET_DIR) +if rows is not None and len(rows) == 0: + logger.info("Empty vector store table was successfully created") +else: + logger.error(rows) + +rows = create_vector_store(DEFAULT_ASKME_SCHEMA_NAME, HEATWAVE_MANUALS_VECTOR_TABLE_NAME, HEATWAVE_MANUALS_BUCKET_DIR) +if rows is not None and len(rows) == 0: + logger.info("HeatWave manual vector store table was successfully created") +else: + logger.error(rows) \ No newline at end of file diff --git a/askme/styles/style.css b/askme/styles/style.css new file mode 100644 index 0000000..4af92da --- /dev/null +++ b/askme/styles/style.css @@ -0,0 +1,19 @@ +div.stButton > button { + width: 100% !important; + height: 40px !important; + font-size: 16px !important; +} +.stMultiSelect [data-baseweb="tag"] { + font-size: 12px !important; + padding: 2px 3px !important; + background-color: green !important; + border-radius: 10px !important; + width: auto !important; +} +.stMultiSelect [data-baseweb="tag"] div { + margin-right: 8px !important; +} + +[data-testid="stBaseButton-secondaryFormSubmit"] { + width: 100%; +} \ No newline at end of file diff --git a/askme/terraform/README.md b/askme/terraform/README.md new file mode 100644 index 0000000..ca1eddf --- /dev/null +++ b/askme/terraform/README.md @@ -0,0 +1,188 @@ +[![License: UPL](https://img.shields.io/badge/license-UPL-green)](https://img.shields.io/badge/license-UPL-green) [![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=oracle-devrel_tech-content-heatwave)](https://sonarcloud.io/dashboard?id=oracle-devrel_tech-content-heatwave) + +# Deploy AskME resources with terraform + +This section explains how to deploy AskME in your tenancy, using the OCI Cloud Shell and terraform. The following resources are automatically created during the process: +- A dynamic group and a policy in the root compartment +- A Compartment for the AskME resources +- An Object Storage bucket containing two documents +- A Compute Instance to run the AskME app +- A MySQL DBSystem with a HeatWave cluster +- A Vault, a Vault key and three Vault secrets +- A VCN with an Internet Gateway, two Security Lists and two Subnets + +The application can then be accessed from your local machine, using local port forwarding, as detailed in [step 8](#step-8-use-askme). + +## Step 1: Open OCI Cloud Shell +Sign In to [your OCI tenancy](http://cloud.oracle.com/), switch to a [region supporting OCI Generative AI](https://docs.oracle.com/en-us/iaas/Content/generative-ai/overview.htm#regions) (e.g: US Midwest), and open the Cloud Shell. +You need to have the [Administrator role](https://docs.oracle.com/en-us/iaas/Content/Identity/roles/understand-administrator-roles.htm) to deploy the AskME resources in your tenancy. + +![OCI starting page](assets/oci_home_page.png) + +![OCI starting page, developer tools](assets/oci_home_page_dev_tools.png) + +![OCI starting page, cloud shell](assets/oci_home_page_cloud_shell.png) + +## Step 2: Get the repository archive +In the Cloud Shell interface, fetch the tech-content-heatwave repository archive. + +Command: +``` +wget -O tech-content-heatwave.zip -nv https://github.com/oracle-devrel/tech-content-heatwave/archive/refs/heads/tech-content-heatwave_askme.zip +``` + +## Step 3: Unzip the archive +Command: +``` +unzip tech-content-heatwave.zip '*/askme/*' -d tech-content-heatwave +``` + +## Step 4: Change directory to the terraform folder +Command: +``` +cd tech-content-heatwave/*/askme/terraform +``` + +## Step 5 (optional): Create a screen session to run terraform +Command: +``` +screen -S askme_session +``` + +## Step 6: Run the setup script +Run the script `askme_setup.sh`, and follow the instructions. Additional information will be asked by the script to setup the DBSystem and the compute instance. + +Command: +``` +sh askme_setup.sh +``` + +![Cloud Shell: Run deployment script](assets/cloud_shell_script.png) + +### Step 6.a: Compartment name + +Name of the AskME demo compartment to create (default: `heatwave-genai-askme`). + +If your tenancy already contains a compartment with the default name `heatwave-genai-askme`, please provide another compartment name and press Enter. Otherwise, no need to provide a value, press Enter and the default value will be used. + +![Deployment script: compartment input parameter](assets/cloud_shell_script_compartment.png) + + +### Step 6.b: Allowed IPv4 CIDR block + +Set of IPv4 addresses (CIDR notation) allowed to connect to the compute instance. + +The CIDR notation follows the format: `a.b.c.d/e` where `a`, `b`, `c` and `d` are numbers between 0 and 255, and `e` is a number between 0 and 32. More information about the CIDR block notation in the [Network Overview](https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/overview.htm#:~:text=CIDR%20NOTATION) page. + +Use `0.0.0.0/0` to indicate all IP addresses. The prefix is required (for example, include the /32 if specifying an individual IP address). For more information, check the [Security Rules](https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securityrules.htm) page. + +![Deployment script: IPv4 CIDR block input parameter](assets/cloud_shell_script_ip_cidr.png) + +### Step 6.c: SSH authorized key + +Content of the SSH public key file (OpenSSH format) located in your local machine. More information about SSH keys in the [Key Pair management and generation](https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/managingkeypairs.htm) page. + +![Deployment script: SSH authorized key input parameter](assets/cloud_shell_script_ssh.png) + +## Step 7: Resource deployment +Wait until the terraform deployment finishes. Expected deployment time: 30-40 minutes. + +## Step 8: Use AskME +Connect to the AskME compute instance and access the streamlit page. +Follow the instructions provided in the Cloud Shell output. The instructions should look similar to this: +``` +================================================ +Open a terminal in your local computer, and run: + ssh -L 8501:localhost:8501 opc@x.x.x.x +Then in your web browser, open the URL: + 127.0.0.1:8501 +================================================ +``` + +This information can be displayed again with the command `sh askme_output.sh` from the location described in [step 4](#step-4-change-directory-to-the-terraform-folder). + + +# Cleanup AskME resources with terraform + +This section explains how to remove AskME resources from your tenancy, using the OCI Cloud Shell and terraform. + +#### Warning: Cleanup setup requirement +Please make sure that the AskME resources have been created following the [deployment instructions](#deploy-askme-resources-with-terraform), and that the setup folders/files located in [deployment step 4](#step-4-change-directory-to-the-terraform-folder) have not been modified or removed since the last deployment. + +More specifically, please make sure that the file `terraform.tfstate` still exists there. If not, all resources described in the [deployment instructions](#deploy-askme-resources-with-terraform) need to be removed manually. + +#### Warning: Cleanup retention period +There is a retention period of 30 days before the OCI Vault can be removed, blocking the compartment deletion. Please rerun the [cleanup step 2](#step-2-run-the-cleanup-script) again after 30 days to complete the cleanup process. + +## Step 1: Remove Vector Tables in the AskME app (if any) +Follow the instructions in [step 8](#step-8-use-askme) to access the streamlit page. +In the `Knowledge Base Management` tab, go to the section `Reset Knowledge Base` and follow the page instructions to remove all vector store tables. + +![Remove Vector Tables from AskME](assets/askme_reset_kb.png) + +## Step 2: Run the cleanup script +Follow the instructions in [deployment step 1](#step-1-open-oci-cloud-shell) and [deployment step 4](#step-4-change-directory-to-the-terraform-folder) to use the Cloud Shell from the right location. + +Run the script `sh askme_cleanup.sh`, and follow the instructions. Additional information will be asked by the script to choose the right compartment name. + +Command: +``` +sh askme_cleanup.sh +``` + +![Cloud Shell: Run deployment script](assets/cloud_shell_script_cleanup.png) + +### Step 2.a: Compartment name + +Name of the AskME demo compartment to delete (default: `heatwave-genai-askme`). + +If you used a custom compartment name in [deployment step 6.a](#step-6a-compartment-name), please provide the same compartment name here and press Enter. Otherwise, no need to provide a value, press Enter and the default value will be used. + +![Cleanup script: compartment input parameter](assets/cloud_shell_script_compartment.png) + + +# Troubleshooting + +## Compartment already exists + +![Troubleshooting: compartment already exists](assets/troubleshooting_compartment_exists.png) + +If the compartment already exists in your tenancy, please rerun the [step 6](#step-6-run-the-setup-script) and specify another compartment name in [step 6.a](#step-6a-compartment-name). + +## Current OCI region does not support Generative AI + +![Troubleshooting: region does not support GenAI](assets/troubleshooting_region_genai_support.png) + +Please close the current Cloud Shell session: + +![Exit Cloud Shell](assets/cloud_shell_exit.png) + +![Exit Cloud Shell Confirm](assets/cloud_shell_exit_confirm.png) + +Then change the OCI Console region to a [region supporting OCI Generative AI](https://docs.oracle.com/en-us/iaas/Content/generative-ai/overview.htm#regions) (e.g: US Midwest): + +![Change OCI Region](assets/oci_home_page_change_region.png) + +Then reopen the Cloud Shell ([step 1](#step-1-open-oci-cloud-shell)) and in the same folder as in [step 4](#step-4-change-directory-to-the-terraform-folder), please rerun [step 6](#step-6-run-the-setup-script). + +## Compartment name length must be between 4 and 20 + +![Troubleshooting: bad compartment name format](assets/troubleshooting_compartment_name_format.png) + +Please rerun [step 6](#step-6-run-the-setup-script) and use a smaller/longer compartment name in [step 6.a](#step-6a-compartment-name). + +## Invalid IPv4 CIDR block notation + +![Troubleshooting: bad IPv4 CIDR format](assets/troubleshooting_cidr_format.png) + +Please rerun [step 6](#step-6-run-the-setup-script) and provide a valid IPv4 block range in [step 6.b](#step-6b-allowed-ipv4-cidr-block), following the CIDR block notation. + +More information about the CIDR block notation in the [Network Overview](https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/overview.htm#:~:text=CIDR%20NOTATION) page. + +## The SSH public key value must follow the OpenSSH format + +![Troubleshooting: bad SSH public key format](assets/troubleshooting_ssh_key_format.png) + +Please rerun [step 6](#step-6-run-the-setup-script) and provide a valid SSH public key in [step 6.c](#step-6c-ssh-authorized-key), following the OpenSSH format. + +More information about SSH keys in the [Key Pair management and generation](https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/managingkeypairs.htm) page. \ No newline at end of file diff --git a/askme/terraform/askme_cleanup.sh b/askme/terraform/askme_cleanup.sh new file mode 100644 index 0000000..0356701 --- /dev/null +++ b/askme/terraform/askme_cleanup.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +set -e # Exit script if any command fails + +terraform init +TF_VAR_TENANCY=$OCI_TENANCY TF_VAR_REGION=$OCI_REGION TF_VAR_ip_cidr_block_allowed="127.0.0.1/32" TF_VAR_ssh_authorized_key="ssh-cleanup" terraform destroy -auto-approve \ No newline at end of file diff --git a/askme/terraform/askme_output.sh b/askme/terraform/askme_output.sh new file mode 100644 index 0000000..edd92b2 --- /dev/null +++ b/askme/terraform/askme_output.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +set -e # Exit script if any command fails + +terraform init >/dev/null +public_ip=`terraform output -json askme_instance_public_ip 2>/dev/null || echo ""` +public_ip=`echo $public_ip | tr -d '"'` +if [ -z "$public_ip" ] +then + echo "No terraform resource detected.." + echo "Please run the deployment instructions to create the resources." +else + echo "================================================" + echo "Open a terminal in your local computer, and run:" + echo " ssh -L 8501:localhost:8501 opc@$public_ip" + echo "Then in your web browser, open the URL:" + echo " 127.0.0.1:8501" + echo "================================================" +fi \ No newline at end of file diff --git a/askme/terraform/askme_setup.sh b/askme/terraform/askme_setup.sh new file mode 100644 index 0000000..51d8729 --- /dev/null +++ b/askme/terraform/askme_setup.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +set -e # Exit script if any command fails + +terraform init +TF_VAR_TENANCY=$OCI_TENANCY TF_VAR_REGION=$OCI_REGION terraform apply -auto-approve +sh askme_output.sh \ No newline at end of file diff --git a/askme/terraform/assets/askme_reset_kb.png b/askme/terraform/assets/askme_reset_kb.png new file mode 100644 index 0000000..0f95793 Binary files /dev/null and b/askme/terraform/assets/askme_reset_kb.png differ diff --git a/askme/terraform/assets/cloud_shell_exit.png b/askme/terraform/assets/cloud_shell_exit.png new file mode 100644 index 0000000..7b73bfc Binary files /dev/null and b/askme/terraform/assets/cloud_shell_exit.png differ diff --git a/askme/terraform/assets/cloud_shell_exit_confirm.png b/askme/terraform/assets/cloud_shell_exit_confirm.png new file mode 100644 index 0000000..2eb1054 Binary files /dev/null and b/askme/terraform/assets/cloud_shell_exit_confirm.png differ diff --git a/askme/terraform/assets/cloud_shell_script.png b/askme/terraform/assets/cloud_shell_script.png new file mode 100644 index 0000000..5144d39 Binary files /dev/null and b/askme/terraform/assets/cloud_shell_script.png differ diff --git a/askme/terraform/assets/cloud_shell_script_cleanup.png b/askme/terraform/assets/cloud_shell_script_cleanup.png new file mode 100644 index 0000000..fdebc08 Binary files /dev/null and b/askme/terraform/assets/cloud_shell_script_cleanup.png differ diff --git a/askme/terraform/assets/cloud_shell_script_compartment.png b/askme/terraform/assets/cloud_shell_script_compartment.png new file mode 100644 index 0000000..480649e Binary files /dev/null and b/askme/terraform/assets/cloud_shell_script_compartment.png differ diff --git a/askme/terraform/assets/cloud_shell_script_ip_cidr.png b/askme/terraform/assets/cloud_shell_script_ip_cidr.png new file mode 100644 index 0000000..f0fe75a Binary files /dev/null and b/askme/terraform/assets/cloud_shell_script_ip_cidr.png differ diff --git a/askme/terraform/assets/cloud_shell_script_ssh.png b/askme/terraform/assets/cloud_shell_script_ssh.png new file mode 100644 index 0000000..0d6cd51 Binary files /dev/null and b/askme/terraform/assets/cloud_shell_script_ssh.png differ diff --git a/askme/terraform/assets/oci_home_page.png b/askme/terraform/assets/oci_home_page.png new file mode 100644 index 0000000..14eae30 Binary files /dev/null and b/askme/terraform/assets/oci_home_page.png differ diff --git a/askme/terraform/assets/oci_home_page_change_region.png b/askme/terraform/assets/oci_home_page_change_region.png new file mode 100644 index 0000000..85c6a25 Binary files /dev/null and b/askme/terraform/assets/oci_home_page_change_region.png differ diff --git a/askme/terraform/assets/oci_home_page_cloud_shell.png b/askme/terraform/assets/oci_home_page_cloud_shell.png new file mode 100644 index 0000000..e3b127d Binary files /dev/null and b/askme/terraform/assets/oci_home_page_cloud_shell.png differ diff --git a/askme/terraform/assets/oci_home_page_dev_tools.png b/askme/terraform/assets/oci_home_page_dev_tools.png new file mode 100644 index 0000000..59af5d0 Binary files /dev/null and b/askme/terraform/assets/oci_home_page_dev_tools.png differ diff --git a/askme/terraform/assets/troubleshooting_cidr_format.png b/askme/terraform/assets/troubleshooting_cidr_format.png new file mode 100644 index 0000000..9c6433a Binary files /dev/null and b/askme/terraform/assets/troubleshooting_cidr_format.png differ diff --git a/askme/terraform/assets/troubleshooting_compartment_exists.png b/askme/terraform/assets/troubleshooting_compartment_exists.png new file mode 100644 index 0000000..8512e1f Binary files /dev/null and b/askme/terraform/assets/troubleshooting_compartment_exists.png differ diff --git a/askme/terraform/assets/troubleshooting_compartment_name_format.png b/askme/terraform/assets/troubleshooting_compartment_name_format.png new file mode 100644 index 0000000..f55b3a7 Binary files /dev/null and b/askme/terraform/assets/troubleshooting_compartment_name_format.png differ diff --git a/askme/terraform/assets/troubleshooting_region_genai_support.png b/askme/terraform/assets/troubleshooting_region_genai_support.png new file mode 100644 index 0000000..787cfc1 Binary files /dev/null and b/askme/terraform/assets/troubleshooting_region_genai_support.png differ diff --git a/askme/terraform/assets/troubleshooting_ssh_key_format.png b/askme/terraform/assets/troubleshooting_ssh_key_format.png new file mode 100644 index 0000000..6cec3f6 Binary files /dev/null and b/askme/terraform/assets/troubleshooting_ssh_key_format.png differ diff --git a/askme/terraform/bucket.tf b/askme/terraform/bucket.tf new file mode 100644 index 0000000..6fd25c1 --- /dev/null +++ b/askme/terraform/bucket.tf @@ -0,0 +1,33 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +data "oci_objectstorage_namespace" "askme_namespace" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + # Need to wait a bit longer than bucket creation to get this information + # otherwise namespace not found + depends_on = [ + oci_mysql_mysql_db_system.askme_dbsystem + ] +} + +resource "oci_objectstorage_bucket" "askme_bucket" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + name = "${local.compartment_name}-bucket" + namespace = data.oci_objectstorage_namespace.askme_namespace.namespace + access_type = "NoPublicAccess" + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_objectstorage_object" "askme_empty_file_example" { + bucket = oci_objectstorage_bucket.askme_bucket.name + namespace = data.oci_objectstorage_namespace.askme_namespace.namespace + object = "workaround_empty/empty_doc.pdf" + source = abspath("documents/empty_doc.pdf") +} + +resource "oci_objectstorage_object" "askme_file_example" { + bucket = oci_objectstorage_bucket.askme_bucket.name + namespace = data.oci_objectstorage_namespace.askme_namespace.namespace + object = "documentation/heatwave-en.pdf" + source = abspath("documents/heatwave-en.pdf") +} \ No newline at end of file diff --git a/askme/terraform/compartment.tf b/askme/terraform/compartment.tf new file mode 100644 index 0000000..ea57364 --- /dev/null +++ b/askme/terraform/compartment.tf @@ -0,0 +1,27 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +resource "oci_identity_compartment" "askme_compartment" { + compartment_id = local.tenancy + description = "AskME compartment" + name = local.compartment_name + enable_delete = true + provider = oci.home + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +# Sleep time to make sure the compartment creation is propagated in regions +resource "time_sleep" "compartment_propagation_wait" { + depends_on = [oci_identity_compartment.askme_compartment] + create_duration = "2m" +} + +data "oci_identity_compartment" "get_askme_compartment" { + id = oci_identity_compartment.askme_compartment.id + provider = oci.home + depends_on = [time_sleep.compartment_propagation_wait] +} + +data "oci_identity_availability_domains" "ads" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id +} \ No newline at end of file diff --git a/askme/terraform/constants.tf b/askme/terraform/constants.tf new file mode 100644 index 0000000..7759b70 --- /dev/null +++ b/askme/terraform/constants.tf @@ -0,0 +1,87 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +# Constants for setup +locals { + mysql_shape = "MySQL.8" + mysql_version = "9.2.2" + mysql_username = "admin" + heatwave_shape = "HeatWave.512GB" + compute_shape = "VM.Standard.E4.Flex" + compute_memory = "16" + demos_repo_url = "https://github.com/oracle-devrel/tech-content-heatwave.git" + demos_repo_subfolder = "askme" +} + +# Default values of input variables +locals { + default_compartment_name = "heatwave-genai-askme" +} + +variable "REGION" { + type = string + description = "Identifier of the current region. For more information about OCI regions supporting Generative AI, see https://docs.oracle.com/en-us/iaas/Content/generative-ai/overview.htm#regions" + + validation { + # Can't use a default local value due to terraform limitations + condition = contains(["sa-saopaulo-1", "eu-frankfurt-1", "ap-osaka-1", "uk-london-1", "us-chicago-1"], var.REGION) + error_message = "Current OCI region does not support Generative AI, please choose another region and rerun the demo instructions." + } +} + +variable "TENANCY" { + type = string +} + +variable "compartment_name" { + type = string + description = "Name of the AskME demo compartment (default: 'heatwave-genai-askme')" + + validation { + condition = length(var.compartment_name) == 0 || (length(var.compartment_name) >= 4 && length(var.compartment_name) <= 20) + error_message = "The compartment name length must be between 4 and 20." + } +} + +variable "ip_cidr_block_allowed" { + type = string + description = "AskME compute instance reachability: Allowed IPv4 CIDR block. Set of IPv4 addresses (CIDR notation) allowed to connect to the compute instance. For more information, see https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/overview.htm#:~:text=CIDR%20NOTATION" + + validation { + condition = can(cidrhost(var.ip_cidr_block_allowed, 0)) + error_message = "Invalid IPv4 CIDR block notation. For more information, see https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/overview.htm#:~:text=CIDR%20NOTATION" + } +} + +variable "ssh_authorized_key" { + type = string + description = "AskME compute instance connection: SSH authorized key. Content of the SSH public key file (OpenSSH format) located in your local machine. For more information about Key Pair management and generation, see https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/managingkeypairs.htm" + + validation { + condition = length(var.ssh_authorized_key) > 4 && substr(var.ssh_authorized_key, 0, 4) == "ssh-" + error_message = "The SSH public key value must follow the OpenSSH format, starting with \"ssh-\". For more information, see https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/managingkeypairs.htm" + } +} + +resource "random_password" "mysql_password" { + length = 32 + min_numeric = 1 + min_special = 1 + min_lower = 1 + min_upper = 1 + override_special = "!#$%&*()-_=+[]{}" +} + +locals { + tenancy = var.TENANCY + region = var.REGION + compartment_name = length(var.compartment_name) == 0 ? local.default_compartment_name : var.compartment_name + ip_cidr_block_allowed = var.ip_cidr_block_allowed + ssh_key = var.ssh_authorized_key + mysql_password = random_password.mysql_password.result + mysql_username_vault_secret_name = "mysql_username" + mysql_password_vault_secret_name = "mysql_password" + mysql_host_ip_vault_secret_name = "mysql_host_ip" + resource_tag_key = "demo" + resource_tag_value = "askme" +} \ No newline at end of file diff --git a/askme/terraform/dbsystem.tf b/askme/terraform/dbsystem.tf new file mode 100644 index 0000000..684c679 --- /dev/null +++ b/askme/terraform/dbsystem.tf @@ -0,0 +1,21 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +resource "oci_mysql_mysql_db_system" "askme_dbsystem" { + display_name = "${local.compartment_name}-dbsystem" + availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + shape_name = local.mysql_shape + subnet_id = oci_core_subnet.askme_vcn_private_subnet.id + admin_username = local.mysql_username + admin_password = local.mysql_password + mysql_version = local.mysql_version + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_mysql_heat_wave_cluster" "askme_heatwave_cluster" { + db_system_id = oci_mysql_mysql_db_system.askme_dbsystem.id + cluster_size = 1 + is_lakehouse_enabled = true + shape_name = local.heatwave_shape +} \ No newline at end of file diff --git a/askme/terraform/documents/empty_doc.pdf b/askme/terraform/documents/empty_doc.pdf new file mode 100644 index 0000000..da314d4 Binary files /dev/null and b/askme/terraform/documents/empty_doc.pdf differ diff --git a/askme/terraform/documents/heatwave-en.pdf b/askme/terraform/documents/heatwave-en.pdf new file mode 100644 index 0000000..9f57eaf Binary files /dev/null and b/askme/terraform/documents/heatwave-en.pdf differ diff --git a/askme/terraform/instance.tf b/askme/terraform/instance.tf new file mode 100644 index 0000000..ac45f1c --- /dev/null +++ b/askme/terraform/instance.tf @@ -0,0 +1,50 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +data "oci_core_images" "ol9_latest" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + operating_system = "Oracle Linux" + operating_system_version = "9" + shape = local.compute_shape +} + +resource "oci_core_instance" "askme_instance" { + availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + shape = local.compute_shape + shape_config { + ocpus = "1" + memory_in_gbs = local.compute_memory + } + source_details { + source_id = data.oci_core_images.ol9_latest.images.0.id + source_type = "image" + } + display_name = "${local.compartment_name}-instance" + create_vnic_details { + assign_public_ip = true + subnet_id = oci_core_subnet.askme_vcn_public_subnet.id + } + metadata = { + ssh_authorized_keys = local.ssh_key + user_data = base64encode(templatefile("instance_init.tpl", + { OCI_COMPARTMENT_ID = data.oci_identity_compartment.get_askme_compartment.id, + OCI_REGION = local.region, + REPO_URL = local.demos_repo_url, + REPO_SUBFOLDER = local.demos_repo_subfolder })) + } + preserve_boot_volume = false + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} + depends_on = [ + oci_vault_secret.askme_mysql_host_ip_secret, + oci_vault_secret.askme_mysql_username_secret, + oci_vault_secret.askme_mysql_password_secret, + oci_mysql_heat_wave_cluster.askme_heatwave_cluster + ] +} + +# Sleep time to make sure the instance user_data was executed +resource "time_sleep" "user_agent_wait" { + depends_on = [oci_core_instance.askme_instance] + create_duration = "5m" +} \ No newline at end of file diff --git a/askme/terraform/instance_init.tpl b/askme/terraform/instance_init.tpl new file mode 100644 index 0000000..2d5683c --- /dev/null +++ b/askme/terraform/instance_init.tpl @@ -0,0 +1,102 @@ +#!/bin/bash +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +set -e # Exit script if any command fails + +REPO_URL="${REPO_URL}" +REPO_SUBFOLDER="${REPO_SUBFOLDER}" +# TODO: change to "main" +REPO_BRANCH="tech-content-heatwave_askme" + +# Set path constants +INSTALL_LOGS="/tmp/askme_install.out" +REPO_HOME_FOLDER="/home/opc/demos" +MAIN_SERVICE_NAME="askme.service" +SETUP_SERVICE_NAME="setup.service" +ASKME_FOLDER="$REPO_HOME_FOLDER/$REPO_SUBFOLDER" +ASKME_ENV_PATH="$ASKME_FOLDER/venv" +REQUIREMENTS_FILEPATH="$ASKME_FOLDER/requirements.txt" +MAIN_SERVICE_FILEPATH="$ASKME_FOLDER/$MAIN_SERVICE_NAME" +SETUP_SERVICE_FILEPATH="$ASKME_FOLDER/$SETUP_SERVICE_NAME" +MAIN_FILEPATH="$ASKME_FOLDER/askme.py" +SETUP_FILEPATH="$ASKME_FOLDER/setup.py" + +# Install libraries +sudo dnf --disablerepo="*" -y install https://dev.mysql.com/get/Downloads/MySQL-Shell/mysql-shell-9.2.0-1.el9.x86_64.rpm >> $INSTALL_LOGS 2>&1 +sudo dnf --refresh --disablerepo="*" --enablerepo="ol9_appstream" -y install git >> $INSTALL_LOGS 2>&1 +sudo dnf --refresh --disablerepo="*" --enablerepo="ol9_appstream" -y install python3.9 >> $INSTALL_LOGS 2>&1 +python3 -m ensurepip >> $INSTALL_LOGS 2>&1 +python3 -m pip install --user oci-cli >> $INSTALL_LOGS 2>&1 + +# Fetch repository content +git init $REPO_HOME_FOLDER >> $INSTALL_LOGS 2>&1 +git -C $REPO_HOME_FOLDER remote add -f origin $REPO_URL >> $INSTALL_LOGS 2>&1 +git -C $REPO_HOME_FOLDER config core.sparseCheckout true >> $INSTALL_LOGS 2>&1 +git -C $REPO_HOME_FOLDER sparse-checkout set $REPO_SUBFOLDER >> $INSTALL_LOGS 2>&1 +git -C $REPO_HOME_FOLDER pull origin $REPO_BRANCH >> $INSTALL_LOGS 2>&1 + +# Install python environment and dependencies +python3 -m venv $ASKME_ENV_PATH >> $INSTALL_LOGS 2>&1 +$ASKME_ENV_PATH/bin/pip install -r $REQUIREMENTS_FILEPATH >> $INSTALL_LOGS 2>&1 + +# Create the services +: > $MAIN_SERVICE_FILEPATH +echo "[Unit]" >> $MAIN_SERVICE_FILEPATH +echo "Description=AskME streamlit application" >> $MAIN_SERVICE_FILEPATH +echo "After=network.target" >> $MAIN_SERVICE_FILEPATH +echo "" >> $MAIN_SERVICE_FILEPATH +echo "[Service]" >> $MAIN_SERVICE_FILEPATH +echo 'Environment="OCI_COMPARTMENT_ID=${OCI_COMPARTMENT_ID}"' >> $MAIN_SERVICE_FILEPATH +echo 'Environment="OCI_REGION=${OCI_REGION}"' >> $MAIN_SERVICE_FILEPATH +echo "Restart=always" >> $MAIN_SERVICE_FILEPATH +echo "RestartSec=30" >> $MAIN_SERVICE_FILEPATH +echo "WorkingDirectory=$ASKME_FOLDER" >> $MAIN_SERVICE_FILEPATH +echo "ExecStart=$ASKME_ENV_PATH/bin/python -m streamlit run $MAIN_FILEPATH --server.port 8501 --server.address=127.0.0.1" >> $MAIN_SERVICE_FILEPATH +echo "" >> $MAIN_SERVICE_FILEPATH +echo "[Install]" >> $MAIN_SERVICE_FILEPATH +echo "WantedBy=default.target" >> $MAIN_SERVICE_FILEPATH + +: > $SETUP_SERVICE_FILEPATH +echo "[Unit]" >> $SETUP_SERVICE_FILEPATH +echo "Description=AskME setup" >> $SETUP_SERVICE_FILEPATH +echo "After=network.target" >> $SETUP_SERVICE_FILEPATH +echo "" >> $SETUP_SERVICE_FILEPATH +echo "[Service]" >> $SETUP_SERVICE_FILEPATH +echo 'Environment="OCI_COMPARTMENT_ID=${OCI_COMPARTMENT_ID}"' >> $SETUP_SERVICE_FILEPATH +echo 'Environment="OCI_REGION=${OCI_REGION}"' >> $SETUP_SERVICE_FILEPATH +echo "Type=oneshot" >> $SETUP_SERVICE_FILEPATH +echo "RemainAfterExit=yes" >> $SETUP_SERVICE_FILEPATH +echo "WorkingDirectory=$ASKME_FOLDER" >> $SETUP_SERVICE_FILEPATH +echo "ExecStart=$ASKME_ENV_PATH/bin/python $SETUP_FILEPATH" >> $SETUP_SERVICE_FILEPATH +echo "" >> $SETUP_SERVICE_FILEPATH +echo "[Install]" >> $SETUP_SERVICE_FILEPATH +echo "WantedBy=default.target" >> $SETUP_SERVICE_FILEPATH + +# Set repo file permission +chown -R opc:opc $REPO_HOME_FOLDER >> $INSTALL_LOGS 2>&1 + +# Start the service from user opc +/usr/bin/su - opc -c ' + echo "export OCI_COMPARTMENT_ID=${OCI_COMPARTMENT_ID}" >> ~/.bash_profile + echo "export OCI_REGION=${OCI_REGION}" >> ~/.bash_profile + source ~/.bash_profile + # Session bus required for systemctl + if [ -z "$XDG_RUNTIME_DIR" ] + then + export XDG_RUNTIME_DIR="/run/user/$UID" + fi + if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] + then + export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" + fi + loginctl enable-linger "$(whoami)" + systemctl --user disable --now '"$MAIN_SERVICE_NAME"' >/dev/null 2>&1 + systemctl --user daemon-reload + systemctl --user enable --now '"$MAIN_SERVICE_FILEPATH"' + systemctl --user start '"$MAIN_SERVICE_NAME"' + systemctl --user disable --now '"$SETUP_SERVICE_NAME"' >/dev/null 2>&1 + systemctl --user daemon-reload + systemctl --user enable --now '"$SETUP_SERVICE_FILEPATH"' + systemctl --user start '"$SETUP_SERVICE_NAME"' +' >> $INSTALL_LOGS 2>&1 \ No newline at end of file diff --git a/askme/terraform/outputs.tf b/askme/terraform/outputs.tf new file mode 100644 index 0000000..e015df9 --- /dev/null +++ b/askme/terraform/outputs.tf @@ -0,0 +1,23 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +output "askme_instance_public_ip" { + value = oci_core_instance.askme_instance.public_ip + description = "Public IP of the AskME instance" +} + +output "askme_dbsystem_mysql_host_ip" { + value = oci_mysql_mysql_db_system.askme_dbsystem.ip_address + description = "Private IP of the AskME DBSystem" +} + +output "askme_dbsystem_mysql_username" { + value = local.mysql_username + description = "Username of the AskME DBSystem admin user" +} + +output "askme_dbsystem_mysql_password" { + value = local.mysql_password + description = "Password of the AskME DBSystem admin user" + sensitive = true +} \ No newline at end of file diff --git a/askme/terraform/policy.tf b/askme/terraform/policy.tf new file mode 100644 index 0000000..e57685e --- /dev/null +++ b/askme/terraform/policy.tf @@ -0,0 +1,33 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +resource "oci_identity_dynamic_group" "askme_dynamic_group" { + compartment_id = local.tenancy + description = "AskME dynamic group" + matching_rule = "ANY{instance.compartment.id = '${data.oci_identity_compartment.get_askme_compartment.id}', resource.compartment.id = '${data.oci_identity_compartment.get_askme_compartment.id}'}" + name = "${local.compartment_name}-dynamic-group" + provider = oci.home + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_identity_policy" "askme_policy" { + compartment_id = local.tenancy + description = "AskME policy" + name = "${local.compartment_name}-policy.pl" + provider = oci.home + statements = [ + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to read volume-family in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to read instance-family in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to read objectstorage-namespaces in tenancy", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to read buckets in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to manage objects in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to read vaults in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to read secret-bundles in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to use generative-ai-chat in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to use generative-ai-text-generation in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to use generative-ai-text-summarization in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to use generative-ai-text-embedding in compartment ${data.oci_identity_compartment.get_askme_compartment.name}", + "allow dynamic-group ${oci_identity_dynamic_group.askme_dynamic_group.name} to use generative-ai-model in compartment ${data.oci_identity_compartment.get_askme_compartment.name}" + ] + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} \ No newline at end of file diff --git a/askme/terraform/provider.tf b/askme/terraform/provider.tf new file mode 100644 index 0000000..2905520 --- /dev/null +++ b/askme/terraform/provider.tf @@ -0,0 +1,18 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +# Need this provider when using Cloud Shell terraform +provider "oci" { + auth = "InstancePrincipal" + region = local.region +} + +data "oci_identity_region_subscriptions" "this" { + tenancy_id = local.tenancy +} + +provider "oci" { + auth = "InstancePrincipal" + region = [for i in data.oci_identity_region_subscriptions.this.region_subscriptions : i.region_name if i.is_home_region == true][0] + alias = "home" +} \ No newline at end of file diff --git a/askme/terraform/vault.tf b/askme/terraform/vault.tf new file mode 100644 index 0000000..4e765cd --- /dev/null +++ b/askme/terraform/vault.tf @@ -0,0 +1,56 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +resource "oci_kms_vault" "askme_vault" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + display_name = "${local.compartment_name}-vault" + vault_type = "DEFAULT" + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_kms_key" "askme_vault_key" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + management_endpoint = oci_kms_vault.askme_vault.management_endpoint + display_name = "${local.compartment_name}-vault-key" + key_shape { + algorithm = "AES" + length = 32 + } + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_vault_secret" "askme_mysql_host_ip_secret" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vault_id = oci_kms_vault.askme_vault.id + key_id = oci_kms_key.askme_vault_key.id + secret_name = local.mysql_host_ip_vault_secret_name + secret_content { + content = base64encode(oci_mysql_mysql_db_system.askme_dbsystem.ip_address) + content_type = "BASE64" + } + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_vault_secret" "askme_mysql_username_secret" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vault_id = oci_kms_vault.askme_vault.id + key_id = oci_kms_key.askme_vault_key.id + secret_name = local.mysql_username_vault_secret_name + secret_content { + content = base64encode(local.mysql_username) + content_type = "BASE64" + } + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_vault_secret" "askme_mysql_password_secret" { + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vault_id = oci_kms_vault.askme_vault.id + key_id = oci_kms_key.askme_vault_key.id + secret_name = local.mysql_password_vault_secret_name + secret_content { + content = base64encode(local.mysql_password) + content_type = "BASE64" + } + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} \ No newline at end of file diff --git a/askme/terraform/vcn.module.tf b/askme/terraform/vcn.module.tf new file mode 100644 index 0000000..eefd8ef --- /dev/null +++ b/askme/terraform/vcn.module.tf @@ -0,0 +1,150 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +module "vcn" { + source = "oracle-terraform-modules/vcn/oci" + version = "3.6.0" + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + region = local.region + vcn_name = "${local.compartment_name}-vcn" + create_internet_gateway = true + create_nat_gateway = false + create_service_gateway = false + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_core_security_list" "askme_public_security_list"{ + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vcn_id = module.vcn.vcn_id + display_name = "${local.compartment_name}-security-list-for-public-subnet" + egress_security_rules { + stateless = false + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" + protocol = "all" + } + ingress_security_rules { + stateless = false + source = local.ip_cidr_block_allowed + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml TCP is 6 + protocol = "6" + tcp_options { + min = 22 + max = 22 + } + } + ingress_security_rules { + stateless = false + source = local.ip_cidr_block_allowed + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml ICMP is 1 + protocol = "1" + # For ICMP type and code see: https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml + icmp_options { + type = 3 + code = 4 + } + } + ingress_security_rules { + stateless = false + source = "10.0.0.0/16" + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml ICMP is 1 + protocol = "1" + # For ICMP type and code see: https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml + icmp_options { + type = 3 + } + } + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_core_subnet" "askme_vcn_public_subnet"{ + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vcn_id = module.vcn.vcn_id + cidr_block = "10.0.0.0/24" + route_table_id = module.vcn.ig_route_id + security_list_ids = [oci_core_security_list.askme_public_security_list.id] + display_name = "${local.compartment_name}-public-subnet" + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_core_security_list" "askme_private_security_list"{ + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vcn_id = module.vcn.vcn_id + display_name = "${local.compartment_name}-security-list-for-private-subnet" + egress_security_rules { + stateless = false + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" + protocol = "all" + } + ingress_security_rules { + stateless = false + source = "10.0.0.0/16" + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml TCP is 6 + protocol = "6" + tcp_options { + min = 22 + max = 22 + } + } + ingress_security_rules { + stateless = false + source = "10.0.0.0/16" + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml ICMP is 1 + protocol = "1" + # For ICMP type and code see: https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml + icmp_options { + type = 3 + code = 4 + } + } + ingress_security_rules { + stateless = false + source = "10.0.0.0/16" + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml ICMP is 1 + protocol = "1" + # For ICMP type and code see: https://www.iana.org/assignments/icmp-parameters/icmp-parameters.xhtml + icmp_options { + type = 3 + } + } + ingress_security_rules { + stateless = false + source = "10.0.0.0/16" + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml TCP is 6 + protocol = "6" + tcp_options { + min = 3306 + max = 3306 + } + } + ingress_security_rules { + stateless = false + source = "10.0.0.0/16" + source_type = "CIDR_BLOCK" + # Get protocol numbers from https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml TCP is 6 + protocol = "6" + tcp_options { + min = 33060 + max = 33060 + } + } + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} + +resource "oci_core_subnet" "askme_vcn_private_subnet"{ + compartment_id = data.oci_identity_compartment.get_askme_compartment.id + vcn_id = module.vcn.vcn_id + cidr_block = "10.0.1.0/24" + route_table_id = module.vcn.nat_route_id + security_list_ids = [oci_core_security_list.askme_private_security_list.id] + display_name = "${local.compartment_name}-private-subnet" + freeform_tags = {"${local.resource_tag_key}"="${local.resource_tag_value}"} +} \ No newline at end of file diff --git a/askme/terraform/versions.tf b/askme/terraform/versions.tf new file mode 100644 index 0000000..c978a8b --- /dev/null +++ b/askme/terraform/versions.tf @@ -0,0 +1,12 @@ +## Copyright (c) 2025 Oracle and/or its affiliates. +## Licensed under the Universal Permissive License (UPL), Version 1.0. + +terraform { + required_providers { + oci = { + source = "oracle/oci" + version = ">=4.67.3" + } + } + required_version = ">= 1.0.0" +} \ No newline at end of file diff --git a/askme/utils/exceptions.py b/askme/utils/exceptions.py new file mode 100644 index 0000000..9447264 --- /dev/null +++ b/askme/utils/exceptions.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +class AskMEException(Exception): + pass + +class BackendConfigurationException(AskMEException): + pass + +class BackendConnectionException(AskMEException): + pass + +class BackendExecutionException(AskMEException): + pass + +class UnknownException(AskMEException): + pass \ No newline at end of file diff --git a/askme/utils/genai_helper.py b/askme/utils/genai_helper.py new file mode 100644 index 0000000..6082402 --- /dev/null +++ b/askme/utils/genai_helper.py @@ -0,0 +1,422 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +from utils.mysql_helper import mysql_connect, run_mysql_queries +from utils.oci_helper import REGION_ID, get_db_credentials, get_namespace, get_askme_bucket_name, upload_object_store_bytes, delete_object_store_folder +import json +from contextlib import closing +import re +import os +from utils.exceptions import AskMEException, BackendConnectionException , UnknownException +from constants import ANSWER_SUMMARY_PROMPT, SUMMARY_MAX_PROMPT_SIZE, DEFAULT_EMPTY_VECTOR_TABLE_NAME, DEFAULT_ASKME_SCHEMA_NAME, HEATWAVE_MANUALS_VECTOR_TABLE_NAME +from utils.util import setup_logging +logger = setup_logging() + +def get_connection(schema_name = DEFAULT_ASKME_SCHEMA_NAME): + host, username, password = get_db_credentials() + conn = mysql_connect( + username=username, + password=password, + host=host, + database=schema_name, + port="3306", + connection_timeout=11, + repeat=3 + ) + return conn + +def get_table_list(schema_name): + conn = get_connection(schema_name) + with closing(conn): + query = """ + SELECT TABLE_NAME + FROM performance_schema.rpd_tables rt, performance_schema.rpd_table_id rti + WHERE rt.ID = rti.ID + AND SCHEMA_NAME = %s + AND TABLE_NAME != %s + AND LOAD_STATUS = "AVAIL_RPDGSTABSTATE"; + """ + params = (schema_name, DEFAULT_EMPTY_VECTOR_TABLE_NAME) + tables = run_mysql_queries(query, conn, params) + return tables + +def get_llm_list(schema): + conn = get_connection(schema) + with closing(conn): + query = f""" + SELECT model_name FROM sys.ML_SUPPORTED_LLMS WHERE model_type = 'generation'; + """ + llms = run_mysql_queries(query, conn) + return llms + +def upload_files_oci(files, bucket_dir_name): + bucket_name = get_askme_bucket_name() + for file in files: + upload_object_store_bytes(file.getvalue(), file.name, bucket_name, bucket_dir_name) + +def filename_to_mysql_table_name(filename): + table_name = re.sub(r'[^a-zA-Z0-9]+', '_', filename).strip('_') + if len(table_name) > 0 and not table_name[0].isalpha(): + table_name = 't_' + table_name + return table_name[:20] + +def create_vector_store(schema_name, table_name, bucket_dir_name): + conn = get_connection(schema_name=None) + table_name = filename_to_mysql_table_name(table_name) + with closing(conn): + bucket_name = get_askme_bucket_name() + namespace = get_namespace() + input_data = [ + { + "db_name": schema_name, + "tables": [ + { + "table_name": table_name, + "engine_attribute": { + "dialect": { + "format": "auto_unstructured", + "is_strict_mode": False + }, + "file": [ + { + "pattern": f"{bucket_dir_name.rstrip('/')}/.*", + "bucket": bucket_name, + "region": REGION_ID, + "namespace": namespace + } + ] + } + } + ] + } + ] + input_json = json.dumps(input_data) + options_json = json.dumps({"mode": "normal", "output": "silent"}) + query = """ + SET @input_list = %s; + SET @options = %s; + CALL sys.heatwave_load(@input_list, @options); + """ + params = (input_json, options_json) + run_mysql_queries(query, conn, params) + + query = f""" + SELECT DISTINCT IF(log->>'$.error' <> '', log->>'$.error', log->>'$.warn') + FROM sys.heatwave_autopilot_report + WHERE type IN ("warn", "error") + AND (log->>'$.warn' IS NOT NULL OR log->>'$.error' IS NOT NULL); + """ + response_logs = run_mysql_queries(query, conn) + return response_logs + +def askme_generate_answer(conn, question, selected_model_id, model_list, schema, selected_kb, topk, overlap): + vector_store_tables = [f'`{schema}`.`{schema_table}`' for schema_table in selected_kb] + vector_store_tables.append(f'`{schema}`.`{DEFAULT_EMPTY_VECTOR_TABLE_NAME}`') + + options_json = f''' + {{ + "vector_store": [{",".join(['"' + x + '"' for x in vector_store_tables])}], + "model_options": {{ "model_id": "{selected_model_id}" }}, + "n_citations": {topk}, + "retrieval_options": {{ "segment_overlap": {overlap} }} + }} + ''' + + query = """ + CALL sys.ML_RAG(%s, @output, %s); + SELECT JSON_UNQUOTE(JSON_EXTRACT(@output, '$.text')) AS answer; + """ + params = (question, options_json) + + response = run_mysql_queries(query, conn, params) + + logger.info(f"Running the query: {query}") + response = run_mysql_queries(query, conn, params) + if not response: + logger.warning(f"The selected LLM model {selected_model_id} is out dated, we try other LLM models.") + for model in model_list: + options_json = f''' + {{ + "vector_store": [{",".join(['"' + x + '"' for x in vector_store_tables])}], + "model_options": {{ "model_id": "{model}" }}, + "n_citations": {topk}, + "retrieval_options": {{ "segment_overlap": {overlap} }} + }} + ''' + params = (question, options_json) + response = run_mysql_queries(query, conn, params) + if response: + logger.info(f"The model `{model}` was used to generate the answer.") + break + if response: + response = response[0] + + query = """ + SET @chat_options = COALESCE(@output, '{{}}'); + SELECT JSON_EXTRACT(@output, '$.citations'); + """ + + # prepare citations + citations = run_mysql_queries(query, conn) + if citations and citations[0]: + citations = citations[0].replace("'", "\u0027") + citations = citations.replace('\\"', '\\"') + citations = json.loads(citations) + else: + citations = None + + formatted_results = [] + if citations: + for cite in citations: + formatted_result = { + "index_name": None, + "file_name": os.path.basename(cite['document_name']), + "url": cite['document_name'], + "chunk_id": None, + "content_chunk": cite['segment'], + "similarity_score": 1 - cite['distance'] + } + formatted_results.append(formatted_result) + + return response, formatted_results + +def question_based_KB_summarization(conn, prompt, relevant_doc_chunks_result, selected_model_id, model_list): + grouped_chunks = group_relevant_chunks_by_url(relevant_doc_chunks_result) + context = "" + char_count = len(ANSWER_SUMMARY_PROMPT.format(question=prompt, context=context)) + for doc in grouped_chunks: + chunks = doc['chunks'] + for chunk in chunks: + if char_count + len(chunk['content_chunk']) <= SUMMARY_MAX_PROMPT_SIZE: + context += chunk['content_chunk'] + char_count += len(chunk['content_chunk']) + else: + remaining = SUMMARY_MAX_PROMPT_SIZE - char_count + context += chunk['content_chunk'][:remaining+1] + + prompt = ANSWER_SUMMARY_PROMPT.format(question=prompt, context=context) + + query = """ + SELECT sys.ML_GENERATE(%s, JSON_OBJECT('model_id', %s)) INTO @output; + SELECT JSON_UNQUOTE(JSON_EXTRACT(@output, '$.text')); + """ + params = (prompt, selected_model_id) + response = run_mysql_queries(query, conn, params) + if not response: + logger.warning(f"The selected LLM model {selected_model_id} is out dated, we try other LLM models.") + for model in model_list: + params = (prompt, model) + response = run_mysql_queries(query, conn, params) + if response: + logger.info(f"The model `{model}` was used to generate the answer.") + break + + if response: + response = response[0] + return response + +def delete_table_from_database(schema, table): + conn = get_connection(schema) + pattern = r"^[A-Za-z0-9_]{1,64}$" + if not re.match(pattern, schema) or not re.match(pattern, table): + logger.warning(f"Possible SQL Injection Attack {schema}.{table}") + raise AskMEException("Invalid schema or table name") + query = f"DROP TABLE IF EXISTS `{schema}`.`{table}`;" + response = run_mysql_queries(query, conn) + return response + +def chatbot_interaction(conn, question, schema, selected_kb, selected_model_id, model_list): + table_objects = [{"table_name": t, "schema_name": schema} for t in selected_kb] + table_objects.append({"table_name": "random_table_name", "schema_name": "random_schema_name"}) + chat_options = { + "tables": table_objects, + "model_options": {"model_id": selected_model_id} + } + query = """ + SET @chat_options = %s; + CALL sys.HEATWAVE_CHAT(%s); + """ + params = ( + json.dumps(chat_options), + question + ) + response = run_mysql_queries(query, conn, params) + + if not response: + logger.warning(f"The selected LLM model {selected_model_id} is out dated, we try other LLM models.") + for model in model_list: + chat_options = { + "tables": table_objects, + "model_options": {"model_id": model} + } + params = ( + json.dumps(chat_options), + question + ) + response = run_mysql_queries(query, conn, params) + if response: + logger.info(f"The model `{model}` was used to generate the answer.") + break + + query = """ + SET @chat_options = COALESCE(@chat_options, '{{}}'); + SELECT JSON_EXTRACT(@chat_options, '$.documents'); + """ + + citations = run_mysql_queries(query, conn) + if citations and citations[0]: + citations = citations[0].replace("'", "\u0027") + citations = citations.replace('\\"', '\\"') + citations = json.loads(citations) + else: + citations = None + + return response, citations + +def get_chat_history_for_current_session(conn): + query = """ + SET @chat_options = COALESCE(@chat_options, '{}'); + SELECT JSON_EXTRACT(@chat_options, '$.chat_history'); + """ + + response = run_mysql_queries(query, conn) + if response and response[0]: + response = response[0].replace("'", "\u0027") + response = response.replace('\\"', '\\"') + response = json.loads(response) + return response + else: + return [] + +def search_similar_chunks(conn, user_query, schema_name, table_names, topk, num_chunks_before, num_chunks_after, min_similarity_score=0.0, distance_metric='COSINE', embedding_model_id='all_minilm_l12_v2'): + """ + Searches for similar documents across multiple tables. + + Args: + - conn: MySQL connection object. + - user_query: User input query string. + - schema_name: Name of the database schema. + - table_names: List of table names to search in. + - topk: Number of top results to return per table. And also as the final result. + - num_chunks_before: Number of chunks before the matched chunk to include. + - num_chunks_after: Number of chunks after the matched chunk to include. + - min_similarity_score: Minimum similarity score required (default=0.0). + - distance_metric: Distance metric used for similarity calculation (default='COSINE'). + - embedding_model_id: ID of the embedding model to use (default='all_minilm_l12_v2'). + + Returns: + A list of dictionaries containing information about the similar documents found. + """ + + # Load the ML model + query = """CALL sys.ML_MODEL_LOAD(%s, NULL);""" + params = (embedding_model_id,) + run_mysql_queries(query, conn, params) + + # Get the input embedding + query = f"""SELECT sys.ML_EMBED_ROW(%s, JSON_OBJECT('model_id', %s)) INTO @input_embedding;""" + params = (user_query, embedding_model_id) + run_mysql_queries(query, conn, params) + + # Set the group concat max length + query = "SET group_concat_max_len = 4096;" + run_mysql_queries(query, conn) + + all_results = [] + for table_name in table_names: + pattern = r"^[A-Za-z0-9_]{1,64}$" + if not re.match(pattern, schema_name) or not re.match(pattern, table_name): + logger.warning(f"Possible SQL Injection Attack {schema_name}.{table_name}") + raise AskMEException("Invalid schema or table name") + + query = f""" + SELECT + %s AS index_name, + t.document_name, + topk_chks.segment_number AS chunk_id, + GROUP_CONCAT(t.segment ORDER BY t.segment_number SEPARATOR ' ') AS content_chunk, + MAX(topk_chks.similarity_score) AS similarity_score + FROM ( + SELECT + document_id, + segment_number, + (1 - DISTANCE(segment_embedding, @input_embedding, %s)) AS similarity_score + FROM `{schema_name}`.`{table_name}` + ORDER BY similarity_score DESC, document_id, segment_number + LIMIT %s + ) topk_chks JOIN `{schema_name}`.`{table_name}` t + ON t.document_id = topk_chks.document_id + WHERE t.segment_number >= CAST(topk_chks.segment_number AS SIGNED) - %s + AND t.segment_number <= CAST(topk_chks.segment_number AS SIGNED) + %s + GROUP BY t.document_id, document_name, topk_chks.segment_number, t.metadata + HAVING similarity_score > %s + ORDER BY similarity_score DESC, document_name, chunk_id + """ + + params = ( + table_name, + distance_metric, + topk, + num_chunks_before, + num_chunks_after, + min_similarity_score + ) + + response = run_mysql_queries(query, conn, params) + all_results.extend(response) + + # Apply global topk and min similarity limits + all_results.sort(key=lambda x: x[-1], reverse=True) # Sort by similarity score in descending order + all_results = [result for result in all_results if result[-1] > min_similarity_score] # Filter by min similarity score + all_results = all_results[:topk] # Take topk results + + # Format the output + formatted_results = [] + for result in all_results: + formatted_result = { + "index_name": result[0], + "file_name": os.path.basename(result[1]), + "url": result[1], + "chunk_id": result[2], + "content_chunk": result[3], + "similarity_score": result[4] + } + formatted_results.append(formatted_result) + + return formatted_results + +def group_relevant_chunks_by_url(relevant_chunks): + grouped_results = {} + for chunk in relevant_chunks: + url = chunk['url'] + if url not in grouped_results: + grouped_results[url] = [] + chunk_info = { + 'title': chunk['file_name'], + 'chunk_id': chunk['chunk_id'], + 'content_chunk': chunk['content_chunk'], + 'similarity_score': chunk['similarity_score'] + } + grouped_results[url].append(chunk_info) + + # Sort chunks within each URL by similarity score in descending order + for url in grouped_results: + grouped_results[url].sort(key=lambda x: x['similarity_score'], reverse=True) + + # Convert the dictionary to a list of dictionaries and sort by maximum similarity score + sorted_grouped_results = [{'url': url, 'chunks': chunks} for url, chunks in grouped_results.items()] + sorted_grouped_results.sort(key=lambda x: max(chunk['similarity_score'] for chunk in x['chunks']), reverse=True) + + return sorted_grouped_results + +def cleanup_vector_table_materials(schema, table_list, bucket_folder_to_delete_prefix): + conn = get_connection() + bucket_name = get_askme_bucket_name() + for table in table_list: + if table not in [DEFAULT_EMPTY_VECTOR_TABLE_NAME, HEATWAVE_MANUALS_VECTOR_TABLE_NAME]: + response = delete_table_from_database(schema, table) + logger.info(f"{schema}.{table} deletion: {response}") + bucket_folder_to_delete = f"{bucket_folder_to_delete_prefix}{table}" + delete_object_store_folder(bucket_name, bucket_folder_to_delete) + logger.info(f"{bucket_folder_to_delete} deletion is done.") + diff --git a/askme/utils/mysql_helper.py b/askme/utils/mysql_helper.py new file mode 100644 index 0000000..dfa3058 --- /dev/null +++ b/askme/utils/mysql_helper.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +import mysql.connector +from utils.util import setup_logging +logger = setup_logging() +from utils.exceptions import AskMEException, BackendConnectionException , UnknownException + +# Connection to the MySQL server +def mysql_connect(username, password, host, database, port, connection_timeout=1, repeat=5): + for i in range(repeat): + try: + return mysql.connector.connect( + user=username, + password=password, + host=host, + port=port, + autocommit=True, + database=database, + ssl_disabled=False, + use_pure=True, + connection_timeout=connection_timeout + ) + except mysql.connector.Error as err: + logger.warning(f"Can't connect to the backend MySQL instance ({i}): {err}") + except Exception as e: + logger.exception(e) + raise UnknownException(e) + logger.warning("Can't connect to the backend MySQL instance") + raise BackendConnectionException("Can't connect to the backend MySQL instance") + +def run_mysql_queries(query, conn=None, params=None): + logger.debug(f"Running query: {query} with parameters: {params}") + output = [] + try: + cursor = conn.cursor() + for cursor_result in cursor.execute(query, multi=True, params=params): + for row in cursor_result: + if len(row) > 0: + output.append(row[0] if len(row) == 1 else row) + return output + except Exception as e: + logger.exception(e) diff --git a/askme/utils/oci_helper.py b/askme/utils/oci_helper.py new file mode 100644 index 0000000..6f69575 --- /dev/null +++ b/askme/utils/oci_helper.py @@ -0,0 +1,170 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +import oci +import os +import base64 +from utils.exceptions import AskMEException, BackendConnectionException , UnknownException +from constants import CLIENT_TIMEOUT, FREEFORM_TAG_KEY, FREEFORM_TAG_VALUE, ADDITIONAL_FIELDS_LIST +from utils.util import setup_logging +logger = setup_logging() + +COMPARTMENT_ID = os.environ['OCI_COMPARTMENT_ID'] + +REGION_ID = os.environ['OCI_REGION'] +RETRY_STRATEGY = oci.retry.NoneRetryStrategy() + +SECRET_MYSQL_USERNAME = "mysql_username" +SECRET_MYSQL_PASSWORD = "mysql_password" +SECRET_MYSQL_HOST_IP = "mysql_host_ip" + +def get_signer_instance_principals(): + try: + # get signer from instance principals token + signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + except Exception: + logger.exception("There was an error while trying to get the oci signer") + raise BackendConnectionException("There was an error while trying to get the oci signer") + return signer + +def get_os_client(): + signer = get_signer_instance_principals() + try: + client = oci.object_storage.ObjectStorageClient(config={}, signer=signer, retry_strategy=RETRY_STRATEGY, timeout=CLIENT_TIMEOUT) + except Exception: + logger.exception("There was an error while trying to get the object storage client") + raise BackendConnectionException("There was an error while trying to get the object storage client") + return client + +def get_vault_client(): + signer = get_signer_instance_principals() + try: + client = oci.key_management.KmsVaultClient(config={}, signer=signer, retry_strategy=RETRY_STRATEGY, timeout=CLIENT_TIMEOUT) + except Exception: + logger.exception("There was an error while trying to get the vault client") + raise BackendConnectionException("There was an error while trying to get the vault client") + return client + +def get_secrets_client(): + signer = get_signer_instance_principals() + try: + client = oci.secrets.SecretsClient(config={}, signer=signer, retry_strategy=RETRY_STRATEGY, timeout=CLIENT_TIMEOUT) + except Exception: + logger.exception("There was an error while trying to get the secret client") + raise BackendConnectionException("There was an error while trying to get the secret client") + return client + +def get_namespace(): + try: + os_client = get_os_client() + namespace = os_client.get_namespace().data + except Exception: + logger.exception("There was an error while trying to fetch the namespace") + raise BackendConnectionException ("There was an error while trying to fetch the namespace") + return namespace + +def get_askme_bucket_name(compartment_id=COMPARTMENT_ID): + os_client = get_os_client() + namespace_name = get_namespace() + oci_response = oci.pagination.list_call_get_all_results(os_client.list_buckets, + namespace_name=namespace_name, + compartment_id=compartment_id, + fields=ADDITIONAL_FIELDS_LIST) + if oci_response.status != 200: + logger.error(f"Can't list the compartment buckets. status: {oci_response.status}") + raise BackendConnectionException(f"Can't list the compartment buckets.") + + all_buckets = oci_response.data + relevant_buckets = [bucket for bucket in all_buckets if bucket.freeform_tags.get(FREEFORM_TAG_KEY) == FREEFORM_TAG_VALUE] + if len(relevant_buckets) != 1: + logger.error(f"Can't find relevant bucket in the compartment {compartment_id}") + raise BackendConnectionException(f"Can't find relevant bucket in the compartment {compartment_id}") + return relevant_buckets[0].name + +def get_vault_id(compartment_id=COMPARTMENT_ID): + vault_client = get_vault_client() + oci_response = oci.pagination.list_call_get_all_results(vault_client.list_vaults, + compartment_id=compartment_id) + if oci_response.status != 200: + logger.error(f"Can't list the compartment vaults. status: {oci_response.status}") + raise BackendConnectionException(f"Can't list the compartment vaults.") + + all_vaults = oci_response.data + relevant_vaults = [vault for vault in all_vaults if vault.freeform_tags.get(FREEFORM_TAG_KEY) == FREEFORM_TAG_VALUE] + if len(relevant_vaults) != 1: + logger.error( f"Can't find relevant vault in the compartment {compartment_id}") + raise BackendConnectionException( f"Can't find relevant vault in the compartment {compartment_id}") + return relevant_vaults[0].id + +def get_secret_value(vault_id, secret_name): + secrets_client = get_secrets_client() + oci_response = secrets_client.get_secret_bundle_by_name(secret_name, vault_id) + if oci_response.status != 200: + logger.error(f"Can't fetch the secret bundle by name. status: {oci_response.status}") + raise BackendConnectionException(f"Can't fetch the secret bundle by name.") + + if oci_response.data.secret_bundle_content.content_type != "BASE64": + logger.error("Secret not using base64 format") + raise BackendConnectionException("Secret not using base64 format") + return base64.b64decode(oci_response.data.secret_bundle_content.content).decode('UTF-8') + +def upload_object_store_object(filepath, bucket_name, prefix): + os_client = get_os_client() + namespace_name = get_namespace() + if not os.path.exists(filepath): + logger.error("File does not exist!") + raise AskMEException("File does not exist!") + + remote_filepath = os.path.join(prefix, os.path.basename(filepath)) + logger.info(f"Uploading {filepath} to object store ({remote_filepath})") + with open(filepath, "rb") as f: + put_object_response = os_client.put_object(namespace_name=namespace_name, + bucket_name=bucket_name, + object_name=remote_filepath, + put_object_body=f) + if put_object_response.status not in [200, 204]: + logger.error(f"Uploading the files to the object store failed. status: {put_object_response.status}") + raise BackendConnectionException("Uploading the files to the object store failed.") + logger.info("Uploading the files to the object store was successful") + +def upload_object_store_bytes(data_bytes, filename, bucket_name, prefix): + os_client = get_os_client() + namespace_name = get_namespace() + remote_filepath = os.path.join(prefix, filename) + logger.info(f"Uploading data to object store ({remote_filepath})") + put_object_response = os_client.put_object(namespace_name=namespace_name, + bucket_name=bucket_name, + object_name=remote_filepath, + put_object_body=data_bytes) + if put_object_response.status not in [200, 204]: + logger.error(f"Uploading the data to the object store failed. status: {put_object_response.status}") + raise BackendConnectionException("Uploading the data to the object store failed.") + logger.info("Uploading the data to the object store was successful") + +def delete_object_store_folder(bucket_name, prefix): + os_client = get_os_client() + namespace_name = get_namespace() + oci_response = oci.pagination.list_call_get_all_results(os_client.list_objects, + namespace_name=namespace_name, + bucket_name=bucket_name, + prefix=prefix) + if oci_response.status != 200: + logger.error(f"Can't list the object store objects from {bucket_name}:{prefix}. status: {oci_response.status}") + raise BackendConnectionException(f"Can't list the object store objects.") + all_os_objects = [os_object for os_object in oci_response.data.objects] + logger.info(f"Removing {len(all_os_objects)} object(s) from '{prefix}'") + for os_object in all_os_objects: + delete_object_response = os_client.delete_object(namespace_name=namespace_name, + bucket_name=bucket_name, + object_name=os_object.name) + if delete_object_response.status not in [200, 204]: + logger.error(f"Deleting the object store object {os_object.name} failed. status: {delete_object_response.status}") + raise BackendConnectionException("Deleting the object store object failed.") + logger.info(f"Deleting the object store object {os_object.name} was successful") + +def get_db_credentials(): + vault_id = get_vault_id() + mysql_username = get_secret_value(vault_id, SECRET_MYSQL_USERNAME) + mysql_password = get_secret_value(vault_id, SECRET_MYSQL_PASSWORD) + mysql_host_ip = get_secret_value(vault_id, SECRET_MYSQL_HOST_IP) + return mysql_host_ip, mysql_username, mysql_password \ No newline at end of file diff --git a/askme/utils/util.py b/askme/utils/util.py new file mode 100644 index 0000000..d715128 --- /dev/null +++ b/askme/utils/util.py @@ -0,0 +1,40 @@ +# Copyright (c) 2025 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License (UPL), Version 1.0. + +import logging +import sys + +class CustomFormatter(logging.Formatter): + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + format = "%(asctime)s - %(levelname)s - %(message)s" + + FORMATS = { + logging.DEBUG: grey + format + reset, + logging.INFO: grey + format + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: bold_red + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + +def setup_logging(): + logging.getLogger().setLevel(logging.INFO) + logger = logging.getLogger(__name__) + if not logger.hasHandlers(): + file = logging.FileHandler('genai_autopilot.log') + file.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')) + file.setLevel(logging.INFO) + logger.addHandler(file) + console = logging.StreamHandler(sys.stdout) + console.setFormatter(CustomFormatter('%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')) + console.setLevel(logging.WARNING) + logger.addHandler(console) + return logger \ No newline at end of file