From 653f1fd43773899c323a0b9973bdab2c8cdb99ec Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 24 May 2024 01:32:54 +0200 Subject: [PATCH 01/25] Gradio Adater implementation --- docs/how-to/visualize_views_code.py | 40 +++++++++++ setup.cfg | 1 + src/dbally/utils/dbcon.py | 13 ++++ src/dbally/utils/gradio_adapter.py | 104 ++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 docs/how-to/visualize_views_code.py create mode 100644 src/dbally/utils/dbcon.py create mode 100644 src/dbally/utils/gradio_adapter.py diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py new file mode 100644 index 00000000..2e5c8e4c --- /dev/null +++ b/docs/how-to/visualize_views_code.py @@ -0,0 +1,40 @@ +import asyncio +import dotenv +import os + +import dbally +from dbally.audit import CLIEventHandler +from dbally.embeddings import LiteLLMEmbeddingClient +from dbally.similarity import SimilarityIndex, SimpleSqlAlchemyFetcher, FaissStore +from dbally.llms.litellm import LiteLLM +from dbally.utils.gradio_adapter import GradioAdapter +from sandbox.quickstart2 import CandidateView, engine, Candidate + +dotenv.load_dotenv() +country_similarity = SimilarityIndex( + fetcher=SimpleSqlAlchemyFetcher( + engine, + table=Candidate, + column=Candidate.country, + ), + store=FaissStore( + index_dir="./similarity_indexes", + index_name="country_similarity", + embedding_client=LiteLLMEmbeddingClient( + api_key=os.environ["OPENAI_API_KEY"], + ), + ), +) + + +async def main(): + llm = LiteLLM(model_name="gpt-3.5-turbo") + collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) + collection.add(CandidateView, lambda: CandidateView(engine)) + gradio_adapter = GradioAdapter(similarity_store=country_similarity) + gradio_interface = gradio_adapter.create_interface(collection) + gradio_interface.launch() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/setup.cfg b/setup.cfg index 62d3bef8..b05cc593 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,7 @@ install_requires = tabulate>=0.9.0 click~=8.1.7 numpy>=1.24.0 + gradio>=4.31.5 [options.extras_require] litellm = diff --git a/src/dbally/utils/dbcon.py b/src/dbally/utils/dbcon.py new file mode 100644 index 00000000..5e28eade --- /dev/null +++ b/src/dbally/utils/dbcon.py @@ -0,0 +1,13 @@ +import pandas as pd +from sqlalchemy import create_engine, inspect + + +def main(): + engine = create_engine(r"sqlite:////home/karllu/projects/db-ally/candidates.db") + insp = inspect(engine) + print(insp.get_table_names()) + pd.read_sql_table(insp.get_table_names()[0], engine) + + +if __name__ == "__main__": + main() diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py new file mode 100644 index 00000000..2f36ee50 --- /dev/null +++ b/src/dbally/utils/gradio_adapter.py @@ -0,0 +1,104 @@ +from typing import Optional, Tuple + +import gradio +import pandas as pd + +from dbally.collection import Collection +from dbally.similarity import SimilarityIndex +from dbally.utils.errors import UnsupportedQueryError + + +class GradioAdapter: + def __init__(self, similarity_store: SimilarityIndex = None): + """Initializes the GradioAdapter with an optional similarity store. + + Args: + similarity_store: An instance of SimilarityIndex for similarity operations. Defaults to None. + """ + self.collection = None + self.similarity_store = similarity_store + self.loaded_dataframe = None + + async def load_data(self, input_dataframe: pd.DataFrame) -> str: + """Loads data into the adapter from a given DataFrame. + + Args: + input_dataframe: The DataFrame to load. + + Returns: + A message indicating the data has been loaded. + """ + if self.similarity_store: + await self.similarity_store.update() + self.loaded_dataframe = input_dataframe + return "Frame data loaded." + + async def load_selected_data(self, selected_view: str) -> Tuple[pd.DataFrame, str]: + """Loads selected view data into the adapter. + + Args: + selected_view: The name of the view to load. + + Returns: + A tuple containing the loaded DataFrame and a message indicating the view data has been loaded. + """ + if self.similarity_store: + await self.similarity_store.update() + self.loaded_dataframe = pd.DataFrame.from_records(self.collection.get(selected_view).execute().results) + return self.loaded_dataframe, f"{selected_view} data loaded." + + async def execute_query(self, query: str) -> Tuple[str, Optional[pd.DataFrame]]: + """Executes a query against the collection. + + Args: + query: The question to ask. + + Returns: + A tuple containing the generated SQL (str) and the resulting DataFrame (pd.DataFrame). + If the query is unsupported, returns a message indicating this and None. + """ + try: + execution_result = await self.collection.ask(query) + result = execution_result.context.get("sql"), pd.DataFrame.from_records(execution_result.results) + except UnsupportedQueryError: + result = "Unsupported query", None + return result + + def create_interface(self, user_collection: Collection) -> Optional[gradio.Interface]: + """Creates a Gradio interface for the provided user collection. + + Args: + user_collection: The user collection to create an interface for. + + Returns: + The created Gradio interface, or None if no views are available in the collection. + """ + view_list = user_collection.list() + if not view_list: + print("There is no data to be loaded") + return None + + self.collection = user_collection + + with gradio.Blocks() as demo: + with gradio.Row(): + with gradio.Column(): + view_dropdown = gradio.Dropdown(label="Available views", choices=view_list) + load_info = gradio.Label(value="No data loaded.") + with gradio.Column(): + loaded_data_frame = gradio.Dataframe(interactive=True, col_count=(4, "Fixed")) + load_data_button = gradio.Button("Load new data") + with gradio.Row(): + query = gradio.Text(label="Ask question") + query_button = gradio.Button("Proceed") + with gradio.Row(): + query_sql_result = gradio.Text(label="Generated SQL") + query_result_frame = gradio.Dataframe(interactive=False) + + view_dropdown.change( + fn=self.load_selected_data, inputs=view_dropdown, outputs=[loaded_data_frame, load_info] + ) + load_data_button.click(fn=self.load_data, inputs=loaded_data_frame, outputs=load_info) + query_button.click(fn=self.execute_query, inputs=[query], outputs=[query_sql_result, query_result_frame]) + + return demo From f13cf4de1e87fed345bdfb34b5fdd2adaff01256 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Tue, 28 May 2024 13:37:55 +0200 Subject: [PATCH 02/25] rm unnecesary file --- src/dbally/utils/dbcon.py | 13 ------------- src/dbally/utils/gradio_adapter.py | 2 ++ 2 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 src/dbally/utils/dbcon.py diff --git a/src/dbally/utils/dbcon.py b/src/dbally/utils/dbcon.py deleted file mode 100644 index 5e28eade..00000000 --- a/src/dbally/utils/dbcon.py +++ /dev/null @@ -1,13 +0,0 @@ -import pandas as pd -from sqlalchemy import create_engine, inspect - - -def main(): - engine = create_engine(r"sqlite:////home/karllu/projects/db-ally/candidates.db") - insp = inspect(engine) - print(insp.get_table_names()) - pd.read_sql_table(insp.get_table_names()[0], engine) - - -if __name__ == "__main__": - main() diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py index 2f36ee50..5ff99b22 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/utils/gradio_adapter.py @@ -9,6 +9,8 @@ class GradioAdapter: + """A class to adapt Gradio interface with a similarity store and data operations.""" + def __init__(self, similarity_store: SimilarityIndex = None): """Initializes the GradioAdapter with an optional similarity store. From 03d8462156d68a9c2844741e76ebc954749325b8 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 31 May 2024 07:56:23 +0200 Subject: [PATCH 03/25] bug --- docs/how-to/visualize_views_code.py | 2 +- src/dbally/utils/gradio_adapter.py | 28 +++++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py index 2e5c8e4c..b12ff142 100644 --- a/docs/how-to/visualize_views_code.py +++ b/docs/how-to/visualize_views_code.py @@ -31,7 +31,7 @@ async def main(): llm = LiteLLM(model_name="gpt-3.5-turbo") collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) - gradio_adapter = GradioAdapter(similarity_store=country_similarity) + gradio_adapter = GradioAdapter(similarity_store=country_similarity, engine=engine) gradio_interface = gradio_adapter.create_interface(collection) gradio_interface.launch() diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py index 5ff99b22..7ccf33fb 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/utils/gradio_adapter.py @@ -11,7 +11,7 @@ class GradioAdapter: """A class to adapt Gradio interface with a similarity store and data operations.""" - def __init__(self, similarity_store: SimilarityIndex = None): + def __init__(self, similarity_store: SimilarityIndex = None, engine=None): """Initializes the GradioAdapter with an optional similarity store. Args: @@ -20,6 +20,8 @@ def __init__(self, similarity_store: SimilarityIndex = None): self.collection = None self.similarity_store = similarity_store self.loaded_dataframe = None + self.selected_view_name = None + self.engine = engine async def load_data(self, input_dataframe: pd.DataFrame) -> str: """Loads data into the adapter from a given DataFrame. @@ -30,24 +32,30 @@ async def load_data(self, input_dataframe: pd.DataFrame) -> str: Returns: A message indicating the data has been loaded. """ - if self.similarity_store: - await self.similarity_store.update() self.loaded_dataframe = input_dataframe + # selected_view = self.collection.get(self.selected_view_name) + # table_view = selected_view._select.froms[0] + # print(type(selected_view)) + # self.loaded_dataframe.to_sql(name=table_view, con=self.engine, if_exists="replace") + return "Frame data loaded." - async def load_selected_data(self, selected_view: str) -> Tuple[pd.DataFrame, str]: + async def load_selected_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str]: """Loads selected view data into the adapter. Args: - selected_view: The name of the view to load. + selected_view_name: The name of the view to load. Returns: A tuple containing the loaded DataFrame and a message indicating the view data has been loaded. """ - if self.similarity_store: - await self.similarity_store.update() - self.loaded_dataframe = pd.DataFrame.from_records(self.collection.get(selected_view).execute().results) - return self.loaded_dataframe, f"{selected_view} data loaded." + self.selected_view_name = selected_view_name + selected_view = self.collection.get(selected_view_name) + selected_view_results = selected_view.execute() + self.loaded_dataframe = pd.DataFrame.from_records(selected_view_results.results) + + # self.loaded_dataframe.to_sql(table, con=selected_view_name._sqlalchemy_engine, if_exists='append') + return self.loaded_dataframe, f"{selected_view_name} data loaded." async def execute_query(self, query: str) -> Tuple[str, Optional[pd.DataFrame]]: """Executes a query against the collection. @@ -60,6 +68,8 @@ async def execute_query(self, query: str) -> Tuple[str, Optional[pd.DataFrame]]: If the query is unsupported, returns a message indicating this and None. """ try: + if self.similarity_store: + await self.similarity_store.update() execution_result = await self.collection.ask(query) result = execution_result.context.get("sql"), pd.DataFrame.from_records(execution_result.results) except UnsupportedQueryError: From 849b4f7a878088c5f3b6b5ee4ff967655fef156c Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Mon, 3 Jun 2024 17:52:41 +0200 Subject: [PATCH 04/25] Logger redirect --- docs/how-to/visualize_views_code.py | 13 ++- src/dbally/utils/gradio_adapter.py | 121 +++++++++++++++--------- src/dbally/utils/gradio_log_redirect.py | 18 ++++ 3 files changed, 107 insertions(+), 45 deletions(-) create mode 100644 src/dbally/utils/gradio_log_redirect.py diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py index b12ff142..0a58f903 100644 --- a/docs/how-to/visualize_views_code.py +++ b/docs/how-to/visualize_views_code.py @@ -2,12 +2,15 @@ import dotenv import os +import sqlalchemy + import dbally from dbally.audit import CLIEventHandler from dbally.embeddings import LiteLLMEmbeddingClient from dbally.similarity import SimilarityIndex, SimpleSqlAlchemyFetcher, FaissStore from dbally.llms.litellm import LiteLLM from dbally.utils.gradio_adapter import GradioAdapter +from examples.freeform import MyText2SqlView from sandbox.quickstart2 import CandidateView, engine, Candidate dotenv.load_dotenv() @@ -26,13 +29,21 @@ ), ) +freeFormEngine = sqlalchemy.create_engine("sqlite:///:memory:") + async def main(): llm = LiteLLM(model_name="gpt-3.5-turbo") + + with freeFormEngine.connect() as connection: + for table_config in MyText2SqlView(engine).get_tables(): + connection.execute(sqlalchemy.text(table_config.ddl)) + collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) + collection.add(MyText2SqlView, lambda: MyText2SqlView(engine)) gradio_adapter = GradioAdapter(similarity_store=country_similarity, engine=engine) - gradio_interface = gradio_adapter.create_interface(collection) + gradio_interface = await gradio_adapter.create_interface(collection) gradio_interface.launch() diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py index 7ccf33fb..4c0057e1 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/utils/gradio_adapter.py @@ -1,16 +1,25 @@ -from typing import Optional, Tuple +import sys +from typing import Optional, Tuple, Dict import gradio import pandas as pd +from dbally import BaseStructuredView from dbally.collection import Collection from dbally.similarity import SimilarityIndex from dbally.utils.errors import UnsupportedQueryError +from dbally.utils.gradio_log_redirect import Logger + + +sys.stdout = Logger("console.log") class GradioAdapter: """A class to adapt Gradio interface with a similarity store and data operations.""" + SQL_RESULT = "sql" + PANDAS_RESULT = "filter_mask" + def __init__(self, similarity_store: SimilarityIndex = None, engine=None): """Initializes the GradioAdapter with an optional similarity store. @@ -20,27 +29,9 @@ def __init__(self, similarity_store: SimilarityIndex = None, engine=None): self.collection = None self.similarity_store = similarity_store self.loaded_dataframe = None - self.selected_view_name = None - self.engine = engine - - async def load_data(self, input_dataframe: pd.DataFrame) -> str: - """Loads data into the adapter from a given DataFrame. - - Args: - input_dataframe: The DataFrame to load. + sys.stdout.flush() - Returns: - A message indicating the data has been loaded. - """ - self.loaded_dataframe = input_dataframe - # selected_view = self.collection.get(self.selected_view_name) - # table_view = selected_view._select.froms[0] - # print(type(selected_view)) - # self.loaded_dataframe.to_sql(name=table_view, con=self.engine, if_exists="replace") - - return "Frame data loaded." - - async def load_selected_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str]: + async def ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str, None, None, None, None]: """Loads selected view data into the adapter. Args: @@ -49,19 +40,26 @@ async def load_selected_data(self, selected_view_name: str) -> Tuple[pd.DataFram Returns: A tuple containing the loaded DataFrame and a message indicating the view data has been loaded. """ - self.selected_view_name = selected_view_name + preview_dataframe, load_status_text = self.load_preview_data(selected_view_name) + return preview_dataframe, load_status_text, None, None, None, None + + def load_preview_data(self, selected_view_name: str): selected_view = self.collection.get(selected_view_name) - selected_view_results = selected_view.execute() - self.loaded_dataframe = pd.DataFrame.from_records(selected_view_results.results) + text_to_display = "No data preview available" + if issubclass(type(selected_view), BaseStructuredView): + selected_view_results = selected_view.execute() + preview_dataframe = pd.DataFrame.from_records(selected_view_results.results) + text_to_display = "Data preview loaded" + else: + preview_dataframe = pd.DataFrame() - # self.loaded_dataframe.to_sql(table, con=selected_view_name._sqlalchemy_engine, if_exists='append') - return self.loaded_dataframe, f"{selected_view_name} data loaded." + return preview_dataframe, text_to_display - async def execute_query(self, query: str) -> Tuple[str, Optional[pd.DataFrame]]: + async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.DataFrame], str]: """Executes a query against the collection. Args: - query: The question to ask. + question_query: The question to ask. Returns: A tuple containing the generated SQL (str) and the resulting DataFrame (pd.DataFrame). @@ -70,13 +68,27 @@ async def execute_query(self, query: str) -> Tuple[str, Optional[pd.DataFrame]]: try: if self.similarity_store: await self.similarity_store.update() - execution_result = await self.collection.ask(query) - result = execution_result.context.get("sql"), pd.DataFrame.from_records(execution_result.results) + + execution_result = await self.collection.ask(question=question_query) + if self.SQL_RESULT in execution_result.context: + generated_query = execution_result.context.get(self.SQL_RESULT) + elif self.PANDAS_RESULT in execution_result.context: + generated_query = execution_result.context.get(self.PANDAS_RESULT) + else: + generated_query = "Unsupported generated query" + + data = pd.DataFrame.from_records(execution_result.results) except UnsupportedQueryError: - result = "Unsupported query", None - return result + generated_query = {"Query": "unsupported"} + data = pd.DataFrame() + finally: + sys.stdout.flush() + with open("output.log", "r") as f: + log = f.read() + + return generated_query, data, log - def create_interface(self, user_collection: Collection) -> Optional[gradio.Interface]: + async def create_interface(self, user_collection: Collection) -> Optional[gradio.Interface]: """Creates a Gradio interface for the provided user collection. Args: @@ -85,32 +97,53 @@ def create_interface(self, user_collection: Collection) -> Optional[gradio.Inter Returns: The created Gradio interface, or None if no views are available in the collection. """ - view_list = user_collection.list() + self.collection = user_collection + view_list = [*user_collection.list()] + print(view_list[0]) + if view_list: + default_selected_view_name = view_list[0] + else: + raise ValueError("No view to display") + if not view_list: print("There is no data to be loaded") return None - self.collection = user_collection - with gradio.Blocks() as demo: with gradio.Row(): with gradio.Column(): - view_dropdown = gradio.Dropdown(label="Available views", choices=view_list) - load_info = gradio.Label(value="No data loaded.") + view_dropdown = gradio.Dropdown( + label="Data View preview", choices=view_list, value=default_selected_view_name + ) with gradio.Column(): - loaded_data_frame = gradio.Dataframe(interactive=True, col_count=(4, "Fixed")) - load_data_button = gradio.Button("Load new data") + data_preview_frame, data_preview_status = self.load_preview_data(view_list[0]) + + data_preview_info = gradio.Text(label="Data preview", value=data_preview_status) + loaded_data_frame = gradio.Dataframe(data_preview_frame) + with gradio.Row(): query = gradio.Text(label="Ask question") query_button = gradio.Button("Proceed") with gradio.Row(): - query_sql_result = gradio.Text(label="Generated SQL") + query_sql_result = gradio.Text(label="Generated query context") query_result_frame = gradio.Dataframe(interactive=False) + with gradio.Row(): + log_console = gradio.Code(label="Logs") view_dropdown.change( - fn=self.load_selected_data, inputs=view_dropdown, outputs=[loaded_data_frame, load_info] + fn=self.ui_load_preview_data, + inputs=view_dropdown, + outputs=[ + loaded_data_frame, + data_preview_info, + query, + query_sql_result, + query_result_frame, + log_console, + ], + ) + query_button.click( + fn=self.ui_ask_query, inputs=[query], outputs=[query_sql_result, query_result_frame, log_console] ) - load_data_button.click(fn=self.load_data, inputs=loaded_data_frame, outputs=load_info) - query_button.click(fn=self.execute_query, inputs=[query], outputs=[query_sql_result, query_result_frame]) return demo diff --git a/src/dbally/utils/gradio_log_redirect.py b/src/dbally/utils/gradio_log_redirect.py new file mode 100644 index 00000000..4fff387f --- /dev/null +++ b/src/dbally/utils/gradio_log_redirect.py @@ -0,0 +1,18 @@ +import sys + + +class Logger: + def __init__(self, filename): + self.terminal = sys.stdout + self.log = open(filename, "w") + + def write(self, message): + self.terminal.write(message) + self.log.write(message) + + def flush(self): + self.terminal.flush() + self.log.flush() + + def isatty(self): + return False From 46be6d81d7b7f2b9d9ec9bd739d346631868d80d Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Tue, 4 Jun 2024 02:40:18 +0200 Subject: [PATCH 05/25] gradio adapter --- docs/how-to/visualize_data.md | 36 ++++++++++++++++++ setup.cfg | 6 ++- src/dbally/utils/gradio_adapter.py | 50 ++++++++++++------------- src/dbally/utils/gradio_log_redirect.py | 18 --------- src/dbally/utils/log_to_file.py | 18 +++++++++ 5 files changed, 83 insertions(+), 45 deletions(-) create mode 100644 docs/how-to/visualize_data.md delete mode 100644 src/dbally/utils/gradio_log_redirect.py create mode 100644 src/dbally/utils/log_to_file.py diff --git a/docs/how-to/visualize_data.md b/docs/how-to/visualize_data.md new file mode 100644 index 00000000..6a891c76 --- /dev/null +++ b/docs/how-to/visualize_data.md @@ -0,0 +1,36 @@ +# How-To: Visualize Views + +There has been implemented Gradio Adapter class to create simple UI interface. It allows to display Data Preview related to Views +and execute user queries. + +## Installation +```bash +pip install dbally[gradio] +``` + +## Create own gradio interface +Define collection with implemented views + +```python + llm = LiteLLM(model_name="gpt-3.5-turbo") + collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) + collection.add(CandidateView, lambda: CandidateView(engine)) + collection.add(SampleText2SQLView, lambda: SampleText2SQLView(prepare_freeform_enginge())) +``` + +Create gradio interface +```python + gradio_adapter = GradioAdapter() + gradio_interface = await gradio_adapter.create_interface(collection, similarity_store_list=[country_similarity]) +``` + +Launch the gradio interface. To publish public interface pass argument `share=True` +```python + gradio_interface.launch() +``` + +The endpoint is set by gradio server by triggering python module with Gradio Adapter launch command. +Private endpoint is set to http://127.0.0.1:7860/ by default. + +## Links +* [Example Gradio Interface](visualize_views_code.py) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 0109094d..42da19cd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,7 +65,11 @@ benchmark = pydantic-settings~=2.0.3 psycopg2-binary~=2.9.9 elasticsearch = - elasticsearch==8.13.1 + elasticsearch~=8.13.1 +gradio = + gradio~=4.31.5 + gradio_client~=0.16.4 + [options.packages.find] where = src diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py index 4c0057e1..2a0c1dd9 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/utils/gradio_adapter.py @@ -1,5 +1,5 @@ import sys -from typing import Optional, Tuple, Dict +from typing import Optional, Tuple, Dict, List import gradio import pandas as pd @@ -8,10 +8,10 @@ from dbally.collection import Collection from dbally.similarity import SimilarityIndex from dbally.utils.errors import UnsupportedQueryError -from dbally.utils.gradio_log_redirect import Logger +from dbally.utils.log_to_file import FileLogger - -sys.stdout = Logger("console.log") +CONSOLE_FILE_NAME = "console.log" +sys.stdout = FileLogger(CONSOLE_FILE_NAME) class GradioAdapter: @@ -20,15 +20,11 @@ class GradioAdapter: SQL_RESULT = "sql" PANDAS_RESULT = "filter_mask" - def __init__(self, similarity_store: SimilarityIndex = None, engine=None): - """Initializes the GradioAdapter with an optional similarity store. - - Args: - similarity_store: An instance of SimilarityIndex for similarity operations. Defaults to None. - """ + def __init__(self, preview_limit: int = 20): + """Initializes the GradioAdapter with an optional similarity store.""" + self.preview_limit = preview_limit + self.similarity_store_list = [] self.collection = None - self.similarity_store = similarity_store - self.loaded_dataframe = None sys.stdout.flush() async def ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str, None, None, None, None]: @@ -40,6 +36,7 @@ async def ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFr Returns: A tuple containing the loaded DataFrame and a message indicating the view data has been loaded. """ + preview_dataframe, load_status_text = self.load_preview_data(selected_view_name) return preview_dataframe, load_status_text, None, None, None, None @@ -48,7 +45,7 @@ def load_preview_data(self, selected_view_name: str): text_to_display = "No data preview available" if issubclass(type(selected_view), BaseStructuredView): selected_view_results = selected_view.execute() - preview_dataframe = pd.DataFrame.from_records(selected_view_results.results) + preview_dataframe = pd.DataFrame.from_records(selected_view_results.results).head(self.preview_limit) text_to_display = "Data preview loaded" else: preview_dataframe = pd.DataFrame() @@ -66,40 +63,38 @@ async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Dat If the query is unsupported, returns a message indicating this and None. """ try: - if self.similarity_store: - await self.similarity_store.update() + for similarity_store in self.similarity_store_list: + await similarity_store.update() execution_result = await self.collection.ask(question=question_query) - if self.SQL_RESULT in execution_result.context: - generated_query = execution_result.context.get(self.SQL_RESULT) - elif self.PANDAS_RESULT in execution_result.context: - generated_query = execution_result.context.get(self.PANDAS_RESULT) - else: - generated_query = "Unsupported generated query" - + generated_query = str(execution_result.context) data = pd.DataFrame.from_records(execution_result.results) except UnsupportedQueryError: generated_query = {"Query": "unsupported"} data = pd.DataFrame() finally: sys.stdout.flush() - with open("output.log", "r") as f: + with open(CONSOLE_FILE_NAME, "r") as f: log = f.read() return generated_query, data, log - async def create_interface(self, user_collection: Collection) -> Optional[gradio.Interface]: + async def create_interface( + self, user_collection: Collection, similarity_store_list: List[SimilarityIndex] = [] + ) -> Optional[gradio.Interface]: """Creates a Gradio interface for the provided user collection. Args: user_collection: The user collection to create an interface for. + similarity_store_list: SimilarityIndex Returns: The created Gradio interface, or None if no views are available in the collection. """ self.collection = user_collection + self.similarity_store_list = similarity_store_list + view_list = [*user_collection.list()] - print(view_list[0]) if view_list: default_selected_view_name = view_list[0] else: @@ -119,7 +114,10 @@ async def create_interface(self, user_collection: Collection) -> Optional[gradio data_preview_frame, data_preview_status = self.load_preview_data(view_list[0]) data_preview_info = gradio.Text(label="Data preview", value=data_preview_status) - loaded_data_frame = gradio.Dataframe(data_preview_frame) + if not data_preview_frame.empty: + loaded_data_frame = gradio.Dataframe(value=data_preview_frame, interactive=False) + else: + loaded_data_frame = gradio.Dataframe(interactive=False) with gradio.Row(): query = gradio.Text(label="Ask question") diff --git a/src/dbally/utils/gradio_log_redirect.py b/src/dbally/utils/gradio_log_redirect.py deleted file mode 100644 index 4fff387f..00000000 --- a/src/dbally/utils/gradio_log_redirect.py +++ /dev/null @@ -1,18 +0,0 @@ -import sys - - -class Logger: - def __init__(self, filename): - self.terminal = sys.stdout - self.log = open(filename, "w") - - def write(self, message): - self.terminal.write(message) - self.log.write(message) - - def flush(self): - self.terminal.flush() - self.log.flush() - - def isatty(self): - return False diff --git a/src/dbally/utils/log_to_file.py b/src/dbally/utils/log_to_file.py new file mode 100644 index 00000000..1c7de7dd --- /dev/null +++ b/src/dbally/utils/log_to_file.py @@ -0,0 +1,18 @@ +import sys + + +class FileLogger: + def __init__(self, filename): + self.logFile = open(filename, "w") + self.console = sys.stdout + + def write(self, message): + self.logFile.write(message) + self.console.write(message) + + def flush(self): + self.logFile.flush() + self.console.flush() + + def isatty(self): + return False From d0ff2274f222cd26f48831cf85027484f41c7816 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Tue, 4 Jun 2024 02:40:51 +0200 Subject: [PATCH 06/25] docs part1' --- docs/how-to/visualize_views_code.py | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py index 0a58f903..85da39f3 100644 --- a/docs/how-to/visualize_views_code.py +++ b/docs/how-to/visualize_views_code.py @@ -3,6 +3,7 @@ import os import sqlalchemy +from sqlalchemy import text import dbally from dbally.audit import CLIEventHandler @@ -10,8 +11,8 @@ from dbally.similarity import SimilarityIndex, SimpleSqlAlchemyFetcher, FaissStore from dbally.llms.litellm import LiteLLM from dbally.utils.gradio_adapter import GradioAdapter -from examples.freeform import MyText2SqlView from sandbox.quickstart2 import CandidateView, engine, Candidate +from tests.unit.views.text2sql.test_view import SampleText2SQLView dotenv.load_dotenv() country_similarity = SimilarityIndex( @@ -29,21 +30,34 @@ ), ) -freeFormEngine = sqlalchemy.create_engine("sqlite:///:memory:") +def prepare_freeform_enginge(): + engine = sqlalchemy.create_engine("sqlite:///:memory:") -async def main(): - llm = LiteLLM(model_name="gpt-3.5-turbo") + statements = [ + "CREATE TABLE security_specialists (id INTEGER PRIMARY KEY, name TEXT, cypher TEXT)", + "INSERT INTO security_specialists (name, cypher) VALUES ('Alice', 'HAMAC')", + "INSERT INTO security_specialists (name, cypher) VALUES ('Bob', 'AES')", + "INSERT INTO security_specialists (name, cypher) VALUES ('Charlie', 'RSA')", + "INSERT INTO security_specialists (name, cypher) VALUES ('David', 'SHA2')", + ] + + with engine.connect() as conn: + for statement in statements: + conn.execute(text(statement)) + + conn.commit() - with freeFormEngine.connect() as connection: - for table_config in MyText2SqlView(engine).get_tables(): - connection.execute(sqlalchemy.text(table_config.ddl)) + return engine + +async def main(): + llm = LiteLLM(model_name="gpt-3.5-turbo") collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) - collection.add(MyText2SqlView, lambda: MyText2SqlView(engine)) - gradio_adapter = GradioAdapter(similarity_store=country_similarity, engine=engine) - gradio_interface = await gradio_adapter.create_interface(collection) + collection.add(SampleText2SQLView, lambda: SampleText2SQLView(prepare_freeform_enginge())) + gradio_adapter = GradioAdapter() + gradio_interface = await gradio_adapter.create_interface(collection, similarity_store_list=[country_similarity]) gradio_interface.launch() From 27740d6d7636144737721a9deaf41f08cfd5a518 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Tue, 4 Jun 2024 10:41:17 +0200 Subject: [PATCH 07/25] adjustments --- .../{visualize_data.md => visualize_views.md} | 4 +- docs/how-to/visualize_views_code.py | 27 +++++-- mkdocs.yml | 1 + src/dbally/utils/gradio_adapter.py | 74 +++++++++++++------ src/dbally/utils/log_to_file.py | 43 ++++++++++- 5 files changed, 113 insertions(+), 36 deletions(-) rename docs/how-to/{visualize_data.md => visualize_views.md} (80%) diff --git a/docs/how-to/visualize_data.md b/docs/how-to/visualize_views.md similarity index 80% rename from docs/how-to/visualize_data.md rename to docs/how-to/visualize_views.md index 6a891c76..8c239a84 100644 --- a/docs/how-to/visualize_data.md +++ b/docs/how-to/visualize_views.md @@ -1,6 +1,6 @@ # How-To: Visualize Views -There has been implemented Gradio Adapter class to create simple UI interface. It allows to display Data Preview related to Views +To create simple UI interface use [GradioAdapter class](../../src/dbally/utils/gradio_adapter.py) It allows to display Data Preview related to Views and execute user queries. ## Installation @@ -29,7 +29,7 @@ Launch the gradio interface. To publish public interface pass argument `share=Tr gradio_interface.launch() ``` -The endpoint is set by gradio server by triggering python module with Gradio Adapter launch command. +The endpoint is set by by triggering python module with Gradio Adapter launch command. Private endpoint is set to http://127.0.0.1:7860/ by default. ## Links diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py index 85da39f3..598736d6 100644 --- a/docs/how-to/visualize_views_code.py +++ b/docs/how-to/visualize_views_code.py @@ -11,8 +11,8 @@ from dbally.similarity import SimilarityIndex, SimpleSqlAlchemyFetcher, FaissStore from dbally.llms.litellm import LiteLLM from dbally.utils.gradio_adapter import GradioAdapter +from dbally.views.freeform.text2sql import BaseText2SQLView, TableConfig, ColumnConfig from sandbox.quickstart2 import CandidateView, engine, Candidate -from tests.unit.views.text2sql.test_view import SampleText2SQLView dotenv.load_dotenv() country_similarity = SimilarityIndex( @@ -31,8 +31,23 @@ ) +class SampleText2SQLViewCyphers(BaseText2SQLView): + def get_tables(self): + return [ + TableConfig( + name="security_specialists", + columns=[ + ColumnConfig("id", "SERIAL PRIMARY KEY"), + ColumnConfig("name", "VARCHAR(255)"), + ColumnConfig("cypher", "VARCHAR(255)"), + ], + description="Knowledge base", + ) + ] + + def prepare_freeform_enginge(): - engine = sqlalchemy.create_engine("sqlite:///:memory:") + freeform_engine = sqlalchemy.create_engine("sqlite:///:memory:") statements = [ "CREATE TABLE security_specialists (id INTEGER PRIMARY KEY, name TEXT, cypher TEXT)", @@ -42,20 +57,20 @@ def prepare_freeform_enginge(): "INSERT INTO security_specialists (name, cypher) VALUES ('David', 'SHA2')", ] - with engine.connect() as conn: + with freeform_engine.connect() as conn: for statement in statements: conn.execute(text(statement)) conn.commit() - return engine + return freeform_engine async def main(): llm = LiteLLM(model_name="gpt-3.5-turbo") - collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) + collection = dbally.create_collection("new_one", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) - collection.add(SampleText2SQLView, lambda: SampleText2SQLView(prepare_freeform_enginge())) + collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(prepare_freeform_enginge())) gradio_adapter = GradioAdapter() gradio_interface = await gradio_adapter.create_interface(collection, similarity_store_list=[country_similarity]) gradio_interface.launch() diff --git a/mkdocs.yml b/mkdocs.yml index 9e1b971f..27c764b6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,7 @@ nav: - how-to/use_elastic_store.md - how-to/use_custom_similarity_store.md - how-to/update_similarity_indexes.md + - how-to/visualize_views.md - how-to/log_runs_to_langsmith.md - how-to/create_custom_event_handler.md - how-to/openai_assistants_integration.md diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py index 2a0c1dd9..71c2f035 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/utils/gradio_adapter.py @@ -1,13 +1,14 @@ import sys -from typing import Optional, Tuple, Dict, List +from typing import Dict, List, Optional, Tuple import gradio import pandas as pd from dbally import BaseStructuredView from dbally.collection import Collection +from dbally.prompts import PromptTemplateError from dbally.similarity import SimilarityIndex -from dbally.utils.errors import UnsupportedQueryError +from dbally.utils.errors import NoViewFoundError, UnsupportedQueryError from dbally.utils.log_to_file import FileLogger CONSOLE_FILE_NAME = "console.log" @@ -15,32 +16,45 @@ class GradioAdapter: - """A class to adapt Gradio interface with a similarity store and data operations.""" - - SQL_RESULT = "sql" - PANDAS_RESULT = "filter_mask" + """ + A class to adapt and integrate data collection and query execution with Gradio interface components. + """ def __init__(self, preview_limit: int = 20): - """Initializes the GradioAdapter with an optional similarity store.""" + """ + Initializes the GradioAdapter with a preview limit. + + Args: + preview_limit: The maximum number of preview data records to display. Default is 20. + """ self.preview_limit = preview_limit self.similarity_store_list = [] self.collection = None sys.stdout.flush() async def ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str, None, None, None, None]: - """Loads selected view data into the adapter. + """ + Asynchronously loads preview data for a selected view name. Args: - selected_view_name: The name of the view to load. + selected_view_name: The name of the selected view to load preview data for. Returns: - A tuple containing the loaded DataFrame and a message indicating the view data has been loaded. + A tuple containing the preview dataframe, load status text, and four None values to clean gradio fields. """ - preview_dataframe, load_status_text = self.load_preview_data(selected_view_name) return preview_dataframe, load_status_text, None, None, None, None - def load_preview_data(self, selected_view_name: str): + def load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str]: + """ + Loads preview data for a selected view name. + + Args: + selected_view_name: The name of the selected view to load preview data for. + + Returns: + A tuple containing the preview dataframe and load status text. + """ selected_view = self.collection.get(selected_view_name) text_to_display = "No data preview available" if issubclass(type(selected_view), BaseStructuredView): @@ -53,14 +67,14 @@ def load_preview_data(self, selected_view_name: str): return preview_dataframe, text_to_display async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.DataFrame], str]: - """Executes a query against the collection. + """ + Asynchronously processes a query and returns the results. Args: - question_query: The question to ask. + question_query (str): The query to process. Returns: - A tuple containing the generated SQL (str) and the resulting DataFrame (pd.DataFrame). - If the query is unsupported, returns a message indicating this and None. + A tuple containing the generated query context, the query results as a dataframe, and the log output. """ try: for similarity_store in self.similarity_store_list: @@ -72,27 +86,39 @@ async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Dat except UnsupportedQueryError: generated_query = {"Query": "unsupported"} data = pd.DataFrame() + except NoViewFoundError: + generated_query = {"Query": "No view matched to query"} + data = pd.DataFrame() + except PromptTemplateError: + generated_query = {"Query": "No view matched to query"} + data = pd.DataFrame() + finally: sys.stdout.flush() - with open(CONSOLE_FILE_NAME, "r") as f: - log = f.read() + with open(CONSOLE_FILE_NAME, encoding="utf8") as console_log_file: + log = console_log_file.read() return generated_query, data, log async def create_interface( - self, user_collection: Collection, similarity_store_list: List[SimilarityIndex] = [] + self, user_collection: Collection, similarity_store_list: Optional[List[SimilarityIndex]] = None ) -> Optional[gradio.Interface]: - """Creates a Gradio interface for the provided user collection. + """ + Creates a Gradio interface for interacting with the user collection and similarity stores. Args: - user_collection: The user collection to create an interface for. - similarity_store_list: SimilarityIndex + user_collection: The user's collection to interact with. + similarity_store_list: A list of similarity stores. Default is None. Returns: - The created Gradio interface, or None if no views are available in the collection. + The created Gradio interface, or None if no data is available to load. + + Raises: + ValueError: occurs when there is no view define in collection. """ self.collection = user_collection - self.similarity_store_list = similarity_store_list + if not similarity_store_list: + self.similarity_store_list = similarity_store_list view_list = [*user_collection.list()] if view_list: diff --git a/src/dbally/utils/log_to_file.py b/src/dbally/utils/log_to_file.py index 1c7de7dd..ef18e144 100644 --- a/src/dbally/utils/log_to_file.py +++ b/src/dbally/utils/log_to_file.py @@ -2,17 +2,52 @@ class FileLogger: + """ + A class for logging messages to both a file and the console. + + Args: + filename: The name of the file to log messages to. + + Attributes: + log_file: The file object for logging messages. + console: The standard output stream for logging messages. + """ + def __init__(self, filename): - self.logFile = open(filename, "w") + """ + Initializes the FileLogger with the specified filename. + + Args: + filename: The name of the file to log messages to. + """ + self.log_file = None + with open(filename, "w", encoding="utf8") as log_file: + self.log_file = log_file + self.console = sys.stdout - def write(self, message): - self.logFile.write(message) + def write(self, message: str): + """ + Writes the given message to both the log file and the console. + + Args: + message: The message to be logged. + """ + self.log_file.write(message) self.console.write(message) def flush(self): - self.logFile.flush() + """ + Flushes both the log file and the console, ensuring all buffered output is written. + """ + self.log_file.flush() self.console.flush() def isatty(self): + """ + Returns False, indicating that this class does not represent a tty. + + Returns: + bool: Always returns False. + """ return False From eee531c23ee2649014bdb98d88b42446d1610e49 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Tue, 4 Jun 2024 11:15:13 +0200 Subject: [PATCH 08/25] content manager error --- src/dbally/utils/log_to_file.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/dbally/utils/log_to_file.py b/src/dbally/utils/log_to_file.py index ef18e144..a9b3f8ee 100644 --- a/src/dbally/utils/log_to_file.py +++ b/src/dbally/utils/log_to_file.py @@ -21,9 +21,7 @@ def __init__(self, filename): filename: The name of the file to log messages to. """ self.log_file = None - with open(filename, "w", encoding="utf8") as log_file: - self.log_file = log_file - + self.log_file = open(filename, "w", encoding="utf8") self.console = sys.stdout def write(self, message: str): From 585f9b9dcce12ae17f03392063f2cb9e01db5d41 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 02:14:41 +0200 Subject: [PATCH 09/25] fixups --- docs/how-to/visualize_views_code.py | 2 +- setup.cfg | 1 - .../audit/event_handlers/cli_event_handler.py | 56 +++++++++++-------- src/dbally/collection.py | 9 +++ src/dbally/utils/gradio_adapter.py | 51 +++++++---------- src/dbally/utils/log_to_file.py | 51 ----------------- 6 files changed, 62 insertions(+), 108 deletions(-) delete mode 100644 src/dbally/utils/log_to_file.py diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py index 598736d6..a14f83ca 100644 --- a/docs/how-to/visualize_views_code.py +++ b/docs/how-to/visualize_views_code.py @@ -72,7 +72,7 @@ async def main(): collection.add(CandidateView, lambda: CandidateView(engine)) collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(prepare_freeform_enginge())) gradio_adapter = GradioAdapter() - gradio_interface = await gradio_adapter.create_interface(collection, similarity_store_list=[country_similarity]) + gradio_interface = await gradio_adapter.create_interface(collection) gradio_interface.launch() diff --git a/setup.cfg b/setup.cfg index 42da19cd..4a571dbd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,7 +39,6 @@ install_requires = tabulate>=0.9.0 click~=8.1.7 numpy>=1.24.0 - gradio>=4.31.5 [options.extras_require] litellm = diff --git a/src/dbally/audit/event_handlers/cli_event_handler.py b/src/dbally/audit/event_handlers/cli_event_handler.py index 15583f1d..de9d76e7 100644 --- a/src/dbally/audit/event_handlers/cli_event_handler.py +++ b/src/dbally/audit/event_handlers/cli_event_handler.py @@ -1,14 +1,17 @@ +import re +from io import StringIO +from sys import stdout from typing import Optional, Union try: from rich import print as pprint from rich.console import Console from rich.syntax import Syntax + from rich.text import Text RICH_OUTPUT = True except ImportError: RICH_OUTPUT = False - # TODO: remove color tags from bare print pprint = print # type: ignore from dbally.audit.event_handlers.base import EventHandler @@ -18,7 +21,7 @@ class CLIEventHandler(EventHandler): """ This handler displays all interactions between LLM and user happening during `Collection.ask`\ - execution inside the terminal. + execution inside the terminal or store them in the given buffer. ### Usage @@ -34,16 +37,23 @@ class CLIEventHandler(EventHandler): ![Example output from CLIEventHandler](../../assets/event_handler_example.png) """ - def __init__(self) -> None: + def __init__(self, buffer=None) -> None: super().__init__() - self._console = Console() if RICH_OUTPUT else None + self.buffer = buffer + out = self.buffer if buffer else stdout + self._console = Console(file=out, record=True) if RICH_OUTPUT else None - def _print_syntax(self, content: str, lexer: str) -> None: + def _print_syntax(self, content: str, lexer: str = None) -> None: if self._console: - console_content = Syntax(content, lexer, word_wrap=True) + if lexer: + console_content = Syntax(content, lexer, word_wrap=True) + else: + console_content = Text.from_markup(content) self._console.print(console_content) else: - print(content) + pattern = re.escape("[") + ".*" + re.escape("]") + remove_formatting = re.sub(pattern, "", content) + print(remove_formatting) async def request_start(self, user_request: RequestStart) -> None: """ @@ -52,10 +62,9 @@ async def request_start(self, user_request: RequestStart) -> None: Args: user_request: Object containing name of collection and asked query """ - - pprint(f"[orange3 bold]Request starts... \n[orange3 bold]MESSAGE: [grey53]{user_request.question}") - pprint("[grey53]\n=======================================") - pprint("[grey53]=======================================\n") + self._print_syntax(f"[orange3 bold]Request starts... \n[orange3 bold]MESSAGE: [grey53]{user_request.question}") + self._print_syntax("[grey53]\n=======================================") + self._print_syntax("[grey53]=======================================\n") async def event_start(self, event: Union[LLMEvent, SimilarityEvent], request_context: None) -> None: """ @@ -68,16 +77,18 @@ async def event_start(self, event: Union[LLMEvent, SimilarityEvent], request_con """ if isinstance(event, LLMEvent): - pprint(f"[cyan bold]LLM event starts... \n[cyan bold]LLM EVENT PROMPT TYPE: [grey53]{event.type}") + self._print_syntax( + f"[cyan bold]LLM event starts... \n[cyan bold]LLM EVENT PROMPT TYPE: [grey53]{event.type}" + ) if isinstance(event.prompt, tuple): for msg in event.prompt: - pprint(f"\n[orange3]{msg['role']}") + self._print_syntax(f"\n[orange3]{msg['role']}") self._print_syntax(msg["content"], "text") else: self._print_syntax(f"{event.prompt}", "text") elif isinstance(event, SimilarityEvent): - pprint( + self._print_syntax( f"[cyan bold]Similarity event starts... \n" f"[cyan bold]INPUT: [grey53]{event.input_value}\n" f"[cyan bold]STORE: [grey53]{event.store}\n" @@ -95,15 +106,14 @@ async def event_end( request_context: Optional context passed from request_start method event_context: Optional context passed from event_start method """ - if isinstance(event, LLMEvent): - pprint(f"\n[green bold]RESPONSE: {event.response}") - pprint("[grey53]\n=======================================") - pprint("[grey53]=======================================\n") + self._print_syntax(f"\n[green bold]RESPONSE: {event.response}") + self._print_syntax("[grey53]\n=======================================") + self._print_syntax("[grey53]=======================================\n") elif isinstance(event, SimilarityEvent): - pprint(f"[green bold]OUTPUT: {event.output_value}") - pprint("[grey53]\n=======================================") - pprint("[grey53]=======================================\n") + self._print_syntax(f"[green bold]OUTPUT: {event.output_value}") + self._print_syntax("[grey53]\n=======================================") + self._print_syntax("[grey53]=======================================\n") async def request_end(self, output: RequestEnd, request_context: Optional[dict] = None) -> None: """ @@ -113,8 +123,8 @@ async def request_end(self, output: RequestEnd, request_context: Optional[dict] output: The output of the request. request_context: Optional context passed from request_start method """ + self._print_syntax("[green bold]REQUEST OUTPUT:") + self._print_syntax(f"Number of rows: {len(output.result.results)}") - pprint("[green bold]REQUEST OUTPUT:") - pprint(f"Number of rows: {len(output.result.results)}") if "sql" in output.result.context: self._print_syntax(f"{output.result.context['sql']}", "psql") diff --git a/src/dbally/collection.py b/src/dbally/collection.py index 922a6365..089ce0e2 100644 --- a/src/dbally/collection.py +++ b/src/dbally/collection.py @@ -128,6 +128,15 @@ def build_dogs_df_view(): self._views[name] = view self._builders[name] = builder + def add_event_handler(self, event_handler: EventHandler): + """ + Adds an event handler to the list of event handlers. + + Args: + event_handler: The event handler to be added. + """ + self._event_handlers.append(event_handler) + def get(self, name: str) -> BaseView: """ Returns an instance of the view with the given name diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/utils/gradio_adapter.py index 71c2f035..dd618588 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/utils/gradio_adapter.py @@ -1,18 +1,14 @@ -import sys -from typing import Dict, List, Optional, Tuple +from io import StringIO +from typing import Dict, Optional, Tuple import gradio import pandas as pd from dbally import BaseStructuredView +from dbally.audit import CLIEventHandler from dbally.collection import Collection from dbally.prompts import PromptTemplateError -from dbally.similarity import SimilarityIndex from dbally.utils.errors import NoViewFoundError, UnsupportedQueryError -from dbally.utils.log_to_file import FileLogger - -CONSOLE_FILE_NAME = "console.log" -sys.stdout = FileLogger(CONSOLE_FILE_NAME) class GradioAdapter: @@ -28,11 +24,10 @@ def __init__(self, preview_limit: int = 20): preview_limit: The maximum number of preview data records to display. Default is 20. """ self.preview_limit = preview_limit - self.similarity_store_list = [] self.collection = None - sys.stdout.flush() + self.log = StringIO() - async def ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str, None, None, None, None]: + async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str, None, None, None, None]: """ Asynchronously loads preview data for a selected view name. @@ -42,10 +37,10 @@ async def ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFr Returns: A tuple containing the preview dataframe, load status text, and four None values to clean gradio fields. """ - preview_dataframe, load_status_text = self.load_preview_data(selected_view_name) + preview_dataframe, load_status_text = self._load_preview_data(selected_view_name) return preview_dataframe, load_status_text, None, None, None, None - def load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str]: + def _load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str]: """ Loads preview data for a selected view name. @@ -66,7 +61,7 @@ def load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str] return preview_dataframe, text_to_display - async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.DataFrame], str]: + async def _ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.DataFrame], str]: """ Asynchronously processes a query and returns the results. @@ -76,10 +71,9 @@ async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Dat Returns: A tuple containing the generated query context, the query results as a dataframe, and the log output. """ + self.log.seek(0) + self.log.truncate(0) try: - for similarity_store in self.similarity_store_list: - await similarity_store.update() - execution_result = await self.collection.ask(question=question_query) generated_query = str(execution_result.context) data = pd.DataFrame.from_records(execution_result.results) @@ -92,23 +86,17 @@ async def ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Dat except PromptTemplateError: generated_query = {"Query": "No view matched to query"} data = pd.DataFrame() - finally: - sys.stdout.flush() - with open(CONSOLE_FILE_NAME, encoding="utf8") as console_log_file: - log = console_log_file.read() - - return generated_query, data, log + self.log.seek(0) + log_content = self.log.read() + return generated_query, data, log_content - async def create_interface( - self, user_collection: Collection, similarity_store_list: Optional[List[SimilarityIndex]] = None - ) -> Optional[gradio.Interface]: + async def create_interface(self, user_collection: Collection) -> Optional[gradio.Interface]: """ Creates a Gradio interface for interacting with the user collection and similarity stores. Args: user_collection: The user's collection to interact with. - similarity_store_list: A list of similarity stores. Default is None. Returns: The created Gradio interface, or None if no data is available to load. @@ -117,8 +105,7 @@ async def create_interface( ValueError: occurs when there is no view define in collection. """ self.collection = user_collection - if not similarity_store_list: - self.similarity_store_list = similarity_store_list + self.collection.add_event_handler(CLIEventHandler(self.log)) view_list = [*user_collection.list()] if view_list: @@ -137,7 +124,7 @@ async def create_interface( label="Data View preview", choices=view_list, value=default_selected_view_name ) with gradio.Column(): - data_preview_frame, data_preview_status = self.load_preview_data(view_list[0]) + data_preview_frame, data_preview_status = self._load_preview_data(view_list[0]) data_preview_info = gradio.Text(label="Data preview", value=data_preview_status) if not data_preview_frame.empty: @@ -152,10 +139,10 @@ async def create_interface( query_sql_result = gradio.Text(label="Generated query context") query_result_frame = gradio.Dataframe(interactive=False) with gradio.Row(): - log_console = gradio.Code(label="Logs") + log_console = gradio.Code(label="Logs", language="shell") view_dropdown.change( - fn=self.ui_load_preview_data, + fn=self._ui_load_preview_data, inputs=view_dropdown, outputs=[ loaded_data_frame, @@ -167,7 +154,7 @@ async def create_interface( ], ) query_button.click( - fn=self.ui_ask_query, inputs=[query], outputs=[query_sql_result, query_result_frame, log_console] + fn=self._ui_ask_query, inputs=[query], outputs=[query_sql_result, query_result_frame, log_console] ) return demo diff --git a/src/dbally/utils/log_to_file.py b/src/dbally/utils/log_to_file.py deleted file mode 100644 index a9b3f8ee..00000000 --- a/src/dbally/utils/log_to_file.py +++ /dev/null @@ -1,51 +0,0 @@ -import sys - - -class FileLogger: - """ - A class for logging messages to both a file and the console. - - Args: - filename: The name of the file to log messages to. - - Attributes: - log_file: The file object for logging messages. - console: The standard output stream for logging messages. - """ - - def __init__(self, filename): - """ - Initializes the FileLogger with the specified filename. - - Args: - filename: The name of the file to log messages to. - """ - self.log_file = None - self.log_file = open(filename, "w", encoding="utf8") - self.console = sys.stdout - - def write(self, message: str): - """ - Writes the given message to both the log file and the console. - - Args: - message: The message to be logged. - """ - self.log_file.write(message) - self.console.write(message) - - def flush(self): - """ - Flushes both the log file and the console, ensuring all buffered output is written. - """ - self.log_file.flush() - self.console.flush() - - def isatty(self): - """ - Returns False, indicating that this class does not represent a tty. - - Returns: - bool: Always returns False. - """ - return False From b9f0aaefb8d7e9b56d57a1b3f5a835d1c90fcc61 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 03:07:54 +0200 Subject: [PATCH 10/25] fixup --- docs/how-to/visualize_views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index 8c239a84..5b141ba1 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -21,7 +21,7 @@ Define collection with implemented views Create gradio interface ```python gradio_adapter = GradioAdapter() - gradio_interface = await gradio_adapter.create_interface(collection, similarity_store_list=[country_similarity]) + gradio_interface = await gradio_adapter.create_interface(collection) ``` Launch the gradio interface. To publish public interface pass argument `share=True` From d65f81391b25bde27220e0c058c2743fa88e35f5 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 03:13:52 +0200 Subject: [PATCH 11/25] fix linters --- src/dbally/audit/event_handlers/cli_event_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dbally/audit/event_handlers/cli_event_handler.py b/src/dbally/audit/event_handlers/cli_event_handler.py index de9d76e7..2f8862fb 100644 --- a/src/dbally/audit/event_handlers/cli_event_handler.py +++ b/src/dbally/audit/event_handlers/cli_event_handler.py @@ -37,7 +37,7 @@ class CLIEventHandler(EventHandler): ![Example output from CLIEventHandler](../../assets/event_handler_example.png) """ - def __init__(self, buffer=None) -> None: + def __init__(self, buffer: StringIO = None) -> None: super().__init__() self.buffer = buffer out = self.buffer if buffer else stdout From 7e424acf23c45f29f5b4f1681e64d8b4ff31c3b0 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 09:44:20 +0200 Subject: [PATCH 12/25] enhance formatting cut --- src/dbally/audit/event_handlers/cli_event_handler.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/dbally/audit/event_handlers/cli_event_handler.py b/src/dbally/audit/event_handlers/cli_event_handler.py index 2f8862fb..f738f90b 100644 --- a/src/dbally/audit/event_handlers/cli_event_handler.py +++ b/src/dbally/audit/event_handlers/cli_event_handler.py @@ -17,6 +17,9 @@ from dbally.audit.event_handlers.base import EventHandler from dbally.data_models.audit import LLMEvent, RequestEnd, RequestStart, SimilarityEvent +_RICH_FORMATING_KEYWORD_SET = {"green", "orange", "grey", "bold", "cyan"} +_RICH_FORMATING_PATTERN = rf"\[.*({'|'.join(_RICH_FORMATING_KEYWORD_SET)}).*\]" + class CLIEventHandler(EventHandler): """ @@ -39,6 +42,7 @@ class CLIEventHandler(EventHandler): def __init__(self, buffer: StringIO = None) -> None: super().__init__() + self.buffer = buffer out = self.buffer if buffer else stdout self._console = Console(file=out, record=True) if RICH_OUTPUT else None @@ -51,9 +55,8 @@ def _print_syntax(self, content: str, lexer: str = None) -> None: console_content = Text.from_markup(content) self._console.print(console_content) else: - pattern = re.escape("[") + ".*" + re.escape("]") - remove_formatting = re.sub(pattern, "", content) - print(remove_formatting) + content_without_formatting = re.sub(_RICH_FORMATING_PATTERN, "", content) + print(content_without_formatting) async def request_start(self, user_request: RequestStart) -> None: """ From ceb0ae8a18ca5b50594b77e03f9dc06b92bd44b9 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 14:24:19 +0200 Subject: [PATCH 13/25] fixups --- docs/how-to/visualize_views.md | 4 +- docs/how-to/visualize_views_code.py | 80 ------------------ docs/quickstart/index.md | 2 +- .../candidate_view_with_similarity_store.py | 70 +++++++++++++++ .../recruting}/candidates.db | Bin examples/recruting/cypher_text2sql_view.py | 40 +++++++++ examples/visualize_views_code.py | 23 +++++ 7 files changed, 136 insertions(+), 83 deletions(-) delete mode 100644 docs/how-to/visualize_views_code.py create mode 100644 examples/recruting/candidate_view_with_similarity_store.py rename {docs/quickstart => examples/recruting}/candidates.db (100%) create mode 100644 examples/recruting/cypher_text2sql_view.py create mode 100644 examples/visualize_views_code.py diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index 5b141ba1..af765454 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -15,7 +15,7 @@ Define collection with implemented views llm = LiteLLM(model_name="gpt-3.5-turbo") collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) - collection.add(SampleText2SQLView, lambda: SampleText2SQLView(prepare_freeform_enginge())) + collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) ``` Create gradio interface @@ -33,4 +33,4 @@ The endpoint is set by by triggering python module with Gradio Adapter launch co Private endpoint is set to http://127.0.0.1:7860/ by default. ## Links -* [Example Gradio Interface](visualize_views_code.py) \ No newline at end of file +* [Example Gradio Interface](../../examples/visualize_views_code.py) \ No newline at end of file diff --git a/docs/how-to/visualize_views_code.py b/docs/how-to/visualize_views_code.py deleted file mode 100644 index a14f83ca..00000000 --- a/docs/how-to/visualize_views_code.py +++ /dev/null @@ -1,80 +0,0 @@ -import asyncio -import dotenv -import os - -import sqlalchemy -from sqlalchemy import text - -import dbally -from dbally.audit import CLIEventHandler -from dbally.embeddings import LiteLLMEmbeddingClient -from dbally.similarity import SimilarityIndex, SimpleSqlAlchemyFetcher, FaissStore -from dbally.llms.litellm import LiteLLM -from dbally.utils.gradio_adapter import GradioAdapter -from dbally.views.freeform.text2sql import BaseText2SQLView, TableConfig, ColumnConfig -from sandbox.quickstart2 import CandidateView, engine, Candidate - -dotenv.load_dotenv() -country_similarity = SimilarityIndex( - fetcher=SimpleSqlAlchemyFetcher( - engine, - table=Candidate, - column=Candidate.country, - ), - store=FaissStore( - index_dir="./similarity_indexes", - index_name="country_similarity", - embedding_client=LiteLLMEmbeddingClient( - api_key=os.environ["OPENAI_API_KEY"], - ), - ), -) - - -class SampleText2SQLViewCyphers(BaseText2SQLView): - def get_tables(self): - return [ - TableConfig( - name="security_specialists", - columns=[ - ColumnConfig("id", "SERIAL PRIMARY KEY"), - ColumnConfig("name", "VARCHAR(255)"), - ColumnConfig("cypher", "VARCHAR(255)"), - ], - description="Knowledge base", - ) - ] - - -def prepare_freeform_enginge(): - freeform_engine = sqlalchemy.create_engine("sqlite:///:memory:") - - statements = [ - "CREATE TABLE security_specialists (id INTEGER PRIMARY KEY, name TEXT, cypher TEXT)", - "INSERT INTO security_specialists (name, cypher) VALUES ('Alice', 'HAMAC')", - "INSERT INTO security_specialists (name, cypher) VALUES ('Bob', 'AES')", - "INSERT INTO security_specialists (name, cypher) VALUES ('Charlie', 'RSA')", - "INSERT INTO security_specialists (name, cypher) VALUES ('David', 'SHA2')", - ] - - with freeform_engine.connect() as conn: - for statement in statements: - conn.execute(text(statement)) - - conn.commit() - - return freeform_engine - - -async def main(): - llm = LiteLLM(model_name="gpt-3.5-turbo") - collection = dbally.create_collection("new_one", llm, event_handlers=[CLIEventHandler()]) - collection.add(CandidateView, lambda: CandidateView(engine)) - collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(prepare_freeform_enginge())) - gradio_adapter = GradioAdapter() - gradio_interface = await gradio_adapter.create_interface(collection) - gradio_interface.launch() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/docs/quickstart/index.md b/docs/quickstart/index.md index 2852e966..bfe4801e 100644 --- a/docs/quickstart/index.md +++ b/docs/quickstart/index.md @@ -30,7 +30,7 @@ pip install dbally[litellm] ## Database Configuration -In this guide, we will use an example SQLAlchemy database containing a single table named `candidates`. This table includes columns such as `id`, `name`, `country`, `years_of_experience`, `position`, `university`, `skills`, and `tags`. You can download the example database from [candidates.db](candidates.db). Alternatively, you can use your own database and models. +In this guide, we will use an example SQLAlchemy database containing a single table named `candidates`. This table includes columns such as `id`, `name`, `country`, `years_of_experience`, `position`, `university`, `skills`, and `tags`. You can download the example database from [candidates.db](../../examples/recruting/candidates.db). Alternatively, you can use your own database and models. To connect to the database using SQLAlchemy, you need an engine and your database models. Start by creating an engine: diff --git a/examples/recruting/candidate_view_with_similarity_store.py b/examples/recruting/candidate_view_with_similarity_store.py new file mode 100644 index 00000000..6203a885 --- /dev/null +++ b/examples/recruting/candidate_view_with_similarity_store.py @@ -0,0 +1,70 @@ +# pylint: disable=missing-return-doc, missing-param-doc, missing-function-docstring +import os + +import sqlalchemy +from sqlalchemy import create_engine +from sqlalchemy.ext.automap import automap_base +from typing_extensions import Annotated + +from dbally import SqlAlchemyBaseView, decorators +from dbally.embeddings.litellm import LiteLLMEmbeddingClient +from dbally.similarity import FaissStore, SimilarityIndex, SimpleSqlAlchemyFetcher + +engine = create_engine("sqlite:///examples/recruting/candidates.db") + +Base = automap_base() +Base.prepare(autoload_with=engine) + +Candidate = Base.classes.candidates + +country_similarity = SimilarityIndex( + fetcher=SimpleSqlAlchemyFetcher( + engine, + table=Candidate, + column=Candidate.country, + ), + store=FaissStore( + index_dir="./similarity_indexes", + index_name="country_similarity", + embedding_client=LiteLLMEmbeddingClient( + model="text-embedding-3-small", # to use openai embedding model + api_key=os.environ["OPENAI_API_KEY"], + ), + ), +) + + +class CandidateView(SqlAlchemyBaseView): + """ + A view for retrieving candidates from the database. + """ + + def get_select(self) -> sqlalchemy.Select: + """ + Creates the initial SqlAlchemy select object, which will be used to build the query. + """ + return sqlalchemy.select(Candidate) + + @decorators.view_filter() + def at_least_experience(self, years: int) -> sqlalchemy.ColumnElement: + """ + Filters candidates with at least `years` of experience. + """ + return Candidate.years_of_experience >= years + + @decorators.view_filter() + def senior_data_scientist_position(self) -> sqlalchemy.ColumnElement: + """ + Filters candidates that can be considered for a senior data scientist position. + """ + return sqlalchemy.and_( + Candidate.position.in_(["Data Scientist", "Machine Learning Engineer", "Data Engineer"]), + Candidate.years_of_experience >= 3, + ) + + @decorators.view_filter() + def from_country(self, country: Annotated[str, country_similarity]) -> sqlalchemy.ColumnElement: + """ + Filters candidates from a specific country. + """ + return Candidate.country == country diff --git a/docs/quickstart/candidates.db b/examples/recruting/candidates.db similarity index 100% rename from docs/quickstart/candidates.db rename to examples/recruting/candidates.db diff --git a/examples/recruting/cypher_text2sql_view.py b/examples/recruting/cypher_text2sql_view.py new file mode 100644 index 00000000..83810d20 --- /dev/null +++ b/examples/recruting/cypher_text2sql_view.py @@ -0,0 +1,40 @@ +# pylint: disable=missing-return-doc, missing-function-docstring, missing-class-docstring, missing-return-type-doc +import sqlalchemy +from sqlalchemy import text + +from dbally.views.freeform.text2sql import BaseText2SQLView, ColumnConfig, TableConfig + + +class SampleText2SQLViewCyphers(BaseText2SQLView): + def get_tables(self): + return [ + TableConfig( + name="security_specialists", + columns=[ + ColumnConfig("id", "SERIAL PRIMARY KEY"), + ColumnConfig("name", "VARCHAR(255)"), + ColumnConfig("cypher", "VARCHAR(255)"), + ], + description="Knowledge base", + ) + ] + + +def create_freeform_memory_engine(): + freeform_engine = sqlalchemy.create_engine("sqlite:///:memory:") + + statements = [ + "CREATE TABLE security_specialists (id INTEGER PRIMARY KEY, name TEXT, cypher TEXT)", + "INSERT INTO security_specialists (name, cypher) VALUES ('Alice', 'HAMAC')", + "INSERT INTO security_specialists (name, cypher) VALUES ('Bob', 'AES')", + "INSERT INTO security_specialists (name, cypher) VALUES ('Charlie', 'RSA')", + "INSERT INTO security_specialists (name, cypher) VALUES ('David', 'SHA2')", + ] + + with freeform_engine.connect() as conn: + for statement in statements: + conn.execute(text(statement)) + + conn.commit() + + return freeform_engine diff --git a/examples/visualize_views_code.py b/examples/visualize_views_code.py new file mode 100644 index 00000000..fbb81f88 --- /dev/null +++ b/examples/visualize_views_code.py @@ -0,0 +1,23 @@ +# pylint: disable=missing-function-docstring +import asyncio + +import dbally +from dbally.audit import CLIEventHandler +from dbally.llms.litellm import LiteLLM +from dbally.utils.gradio_adapter import GradioAdapter +from examples.recruting.candidate_view_with_similarity_store import CandidateView, engine +from examples.recruting.cypher_text2sql_view import SampleText2SQLViewCyphers, create_freeform_memory_engine + + +async def main(): + llm = LiteLLM(model_name="gpt-3.5-turbo") + collection = dbally.create_collection("candidates", llm, event_handlers=[CLIEventHandler()]) + collection.add(CandidateView, lambda: CandidateView(engine)) + collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) + gradio_adapter = GradioAdapter() + gradio_interface = await gradio_adapter.create_interface(collection) + gradio_interface.launch() + + +if __name__ == "__main__": + asyncio.run(main()) From 577b244f051f3678c02cfdb15e42c6f11b4329fe Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 14:45:52 +0200 Subject: [PATCH 14/25] fixups --- docs/how-to/visualize_views.md | 7 ++-- examples/visualize_views_code.py | 8 ++--- src/dbally/gradio/__init__.py | 3 ++ .../gradio_interface.py} | 33 +++++++++++++++---- 4 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 src/dbally/gradio/__init__.py rename src/dbally/{utils/gradio_adapter.py => gradio/gradio_interface.py} (86%) diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index af765454..34efc189 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -1,6 +1,6 @@ # How-To: Visualize Views -To create simple UI interface use [GradioAdapter class](../../src/dbally/utils/gradio_adapter.py) It allows to display Data Preview related to Views +To create simple UI interface use [GradioAdapter class](../../src/dbally/gradio/gradio_interface.py) It allows to display Data Preview related to Views and execute user queries. ## Installation @@ -20,8 +20,9 @@ Define collection with implemented views Create gradio interface ```python - gradio_adapter = GradioAdapter() - gradio_interface = await gradio_adapter.create_interface(collection) + gradio_interface = await create_gradio_interface(user_collection=collection) + if gradio_interface: + gradio_interface.launch() ``` Launch the gradio interface. To publish public interface pass argument `share=True` diff --git a/examples/visualize_views_code.py b/examples/visualize_views_code.py index fbb81f88..97e6cb09 100644 --- a/examples/visualize_views_code.py +++ b/examples/visualize_views_code.py @@ -3,8 +3,8 @@ import dbally from dbally.audit import CLIEventHandler +from dbally.gradio import create_gradio_interface from dbally.llms.litellm import LiteLLM -from dbally.utils.gradio_adapter import GradioAdapter from examples.recruting.candidate_view_with_similarity_store import CandidateView, engine from examples.recruting.cypher_text2sql_view import SampleText2SQLViewCyphers, create_freeform_memory_engine @@ -14,9 +14,9 @@ async def main(): collection = dbally.create_collection("candidates", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) - gradio_adapter = GradioAdapter() - gradio_interface = await gradio_adapter.create_interface(collection) - gradio_interface.launch() + gradio_interface = await create_gradio_interface(user_collection=collection) + if gradio_interface: + gradio_interface.launch() if __name__ == "__main__": diff --git a/src/dbally/gradio/__init__.py b/src/dbally/gradio/__init__.py new file mode 100644 index 00000000..41d84c3c --- /dev/null +++ b/src/dbally/gradio/__init__.py @@ -0,0 +1,3 @@ +from dbally.gradio.gradio_interface import create_gradio_interface + +__all__ = ["create_gradio_interface"] diff --git a/src/dbally/utils/gradio_adapter.py b/src/dbally/gradio/gradio_interface.py similarity index 86% rename from src/dbally/utils/gradio_adapter.py rename to src/dbally/gradio/gradio_interface.py index dd618588..6d939ff9 100644 --- a/src/dbally/utils/gradio_adapter.py +++ b/src/dbally/gradio/gradio_interface.py @@ -11,19 +11,35 @@ from dbally.utils.errors import NoViewFoundError, UnsupportedQueryError -class GradioAdapter: +async def create_gradio_interface(user_collection: Collection, preview_limit: int = 20) -> Optional[gradio.Interface]: + """Adapt and integrate data collection and query execution with Gradio interface components. + + Args: + user_collection: The user's collection to interact with. + preview_limit: The maximum number of preview data records to display. Default is 20. + + Returns: + The created Gradio interface, or None if no data is available to load. + + Raises: + ValueError: occurs when there is no view define in collection. + """ + adapter = _GradioAdapter() + gradio_interface = await adapter.create_interface(user_collection, preview_limit) + return gradio_interface + + +class _GradioAdapter: """ A class to adapt and integrate data collection and query execution with Gradio interface components. """ - def __init__(self, preview_limit: int = 20): + def __init__(self): """ Initializes the GradioAdapter with a preview limit. - Args: - preview_limit: The maximum number of preview data records to display. Default is 20. """ - self.preview_limit = preview_limit + self.preview_limit = None self.collection = None self.log = StringIO() @@ -91,12 +107,15 @@ async def _ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Da log_content = self.log.read() return generated_query, data, log_content - async def create_interface(self, user_collection: Collection) -> Optional[gradio.Interface]: + async def create_interface( + self, user_collection: Collection, preview_limit: int = 20 + ) -> Optional[gradio.Interface]: """ Creates a Gradio interface for interacting with the user collection and similarity stores. Args: user_collection: The user's collection to interact with. + preview_limit: The maximum number of preview data records to display. Default is 20. Returns: The created Gradio interface, or None if no data is available to load. @@ -104,6 +123,8 @@ async def create_interface(self, user_collection: Collection) -> Optional[gradio Raises: ValueError: occurs when there is no view define in collection. """ + + self.preview_limit = preview_limit self.collection = user_collection self.collection.add_event_handler(CLIEventHandler(self.log)) From 9c0cffbf64205e8b51a34132f7e93a80c940f154 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 19:21:42 +0200 Subject: [PATCH 15/25] fixups --- docs/how-to/visualize_views.md | 24 ++++++++------- docs/quickstart/index.md | 2 +- .../candidate_view_with_similarity_store.py | 2 -- src/dbally/gradio/gradio_interface.py | 29 +++++++------------ 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index 34efc189..3a9fb667 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -5,33 +5,37 @@ and execute user queries. ## Installation ```bash -pip install dbally[gradio] +pip install dbally["gradio"] +``` +When You plan to use some other feature like faiss similarity store install them as well. + +```bash +pip install dbally["faiss"] ``` ## Create own gradio interface Define collection with implemented views ```python - llm = LiteLLM(model_name="gpt-3.5-turbo") - collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) - collection.add(CandidateView, lambda: CandidateView(engine)) - collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) +llm = LiteLLM(model_name="gpt-3.5-turbo") +collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) +collection.add(CandidateView, lambda: CandidateView(engine)) +collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) ``` Create gradio interface ```python - gradio_interface = await create_gradio_interface(user_collection=collection) - if gradio_interface: - gradio_interface.launch() +gradio_interface = await create_gradio_interface(user_collection=collection) ``` Launch the gradio interface. To publish public interface pass argument `share=True` ```python +if gradio_interface: gradio_interface.launch() ``` -The endpoint is set by by triggering python module with Gradio Adapter launch command. +The endpoint is set by triggering python module with Gradio Adapter launch command. Private endpoint is set to http://127.0.0.1:7860/ by default. ## Links -* [Example Gradio Interface](../../examples/visualize_views_code.py) \ No newline at end of file +* [Example Gradio Interface](https://github.com/deepsense-ai/db-ally/blob/lk/gradio_adapter/examples/visualize_views_code.py) \ No newline at end of file diff --git a/docs/quickstart/index.md b/docs/quickstart/index.md index bfe4801e..19e09cc1 100644 --- a/docs/quickstart/index.md +++ b/docs/quickstart/index.md @@ -30,7 +30,7 @@ pip install dbally[litellm] ## Database Configuration -In this guide, we will use an example SQLAlchemy database containing a single table named `candidates`. This table includes columns such as `id`, `name`, `country`, `years_of_experience`, `position`, `university`, `skills`, and `tags`. You can download the example database from [candidates.db](../../examples/recruting/candidates.db). Alternatively, you can use your own database and models. +In this guide, we will use an example SQLAlchemy database containing a single table named `candidates`. This table includes columns such as `id`, `name`, `country`, `years_of_experience`, `position`, `university`, `skills`, and `tags`. You can download the example database from [candidates.db](https://github.com/deepsense-ai/db-ally/tree/main/examples/recruting/candidates.db). Alternatively, you can use your own database and models. To connect to the database using SQLAlchemy, you need an engine and your database models. Start by creating an engine: diff --git a/examples/recruting/candidate_view_with_similarity_store.py b/examples/recruting/candidate_view_with_similarity_store.py index 6203a885..34de2f55 100644 --- a/examples/recruting/candidate_view_with_similarity_store.py +++ b/examples/recruting/candidate_view_with_similarity_store.py @@ -1,5 +1,4 @@ # pylint: disable=missing-return-doc, missing-param-doc, missing-function-docstring -import os import sqlalchemy from sqlalchemy import create_engine @@ -28,7 +27,6 @@ index_name="country_similarity", embedding_client=LiteLLMEmbeddingClient( model="text-embedding-3-small", # to use openai embedding model - api_key=os.environ["OPENAI_API_KEY"], ), ), ) diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index 6d939ff9..ee60141f 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -20,16 +20,13 @@ async def create_gradio_interface(user_collection: Collection, preview_limit: in Returns: The created Gradio interface, or None if no data is available to load. - - Raises: - ValueError: occurs when there is no view define in collection. """ - adapter = _GradioAdapter() + adapter = GradioAdapter() gradio_interface = await adapter.create_interface(user_collection, preview_limit) return gradio_interface -class _GradioAdapter: +class GradioAdapter: """ A class to adapt and integrate data collection and query execution with Gradio interface components. """ @@ -119,24 +116,22 @@ async def create_interface( Returns: The created Gradio interface, or None if no data is available to load. - - Raises: - ValueError: occurs when there is no view define in collection. """ self.preview_limit = preview_limit self.collection = user_collection self.collection.add_event_handler(CLIEventHandler(self.log)) + default_selected_view_name = None + data_preview_frame = pd.DataFrame() + data_preview_status = "No view available" + question_interactive = False + view_list = [*user_collection.list()] if view_list: default_selected_view_name = view_list[0] - else: - raise ValueError("No view to display") - - if not view_list: - print("There is no data to be loaded") - return None + data_preview_frame, data_preview_status = self._load_preview_data(view_list[0]) + question_interactive = True with gradio.Blocks() as demo: with gradio.Row(): @@ -145,8 +140,6 @@ async def create_interface( label="Data View preview", choices=view_list, value=default_selected_view_name ) with gradio.Column(): - data_preview_frame, data_preview_status = self._load_preview_data(view_list[0]) - data_preview_info = gradio.Text(label="Data preview", value=data_preview_status) if not data_preview_frame.empty: loaded_data_frame = gradio.Dataframe(value=data_preview_frame, interactive=False) @@ -154,8 +147,8 @@ async def create_interface( loaded_data_frame = gradio.Dataframe(interactive=False) with gradio.Row(): - query = gradio.Text(label="Ask question") - query_button = gradio.Button("Proceed") + query = gradio.Text(label="Ask question", interactive=question_interactive) + query_button = gradio.Button("Proceed", interactive=question_interactive) with gradio.Row(): query_sql_result = gradio.Text(label="Generated query context") query_result_frame = gradio.Dataframe(interactive=False) From 0f91dbf08f60ad684d851573b005ed07af25aa8b Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Thu, 6 Jun 2024 20:37:29 +0200 Subject: [PATCH 16/25] fixups indexes --- docs/how-to/visualize_views.md | 1 + docs/quickstart/index.md | 2 +- .../country_similarity.index | Bin 141357 -> 0 bytes .../similarity_indexes/country_similarity.npy | Bin 1232 -> 0 bytes examples/recruiting.py | 4 ++-- examples/{recruting => recruiting}/__init__.py | 0 .../candidate_view_with_similarity_store.py | 2 +- .../cypher_text2sql_view.py | 0 .../data/application.csv | 0 .../data}/candidates.db | Bin .../{recruting => recruiting}/data/offers.csv | 0 .../data/recruiting.csv | 0 examples/{recruting => recruiting}/db.py | 0 examples/{recruting => recruiting}/views.py | 0 examples/visualize_views_code.py | 5 +++-- 15 files changed, 8 insertions(+), 6 deletions(-) delete mode 100644 docs/quickstart/similarity_indexes/country_similarity.index delete mode 100644 docs/quickstart/similarity_indexes/country_similarity.npy rename examples/{recruting => recruiting}/__init__.py (100%) rename examples/{recruting => recruiting}/candidate_view_with_similarity_store.py (96%) rename examples/{recruting => recruiting}/cypher_text2sql_view.py (100%) rename examples/{recruting => recruiting}/data/application.csv (100%) rename examples/{recruting => recruiting/data}/candidates.db (100%) rename examples/{recruting => recruiting}/data/offers.csv (100%) rename examples/{recruting => recruiting}/data/recruiting.csv (100%) rename examples/{recruting => recruiting}/db.py (100%) rename examples/{recruting => recruiting}/views.py (100%) diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index 3a9fb667..88efb375 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -18,6 +18,7 @@ Define collection with implemented views ```python llm = LiteLLM(model_name="gpt-3.5-turbo") +await country_similarity.update() collection = dbally.create_collection("recruitment", llm, event_handlers=[CLIEventHandler()]) collection.add(CandidateView, lambda: CandidateView(engine)) collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) diff --git a/docs/quickstart/index.md b/docs/quickstart/index.md index 19e09cc1..b18e0ddd 100644 --- a/docs/quickstart/index.md +++ b/docs/quickstart/index.md @@ -30,7 +30,7 @@ pip install dbally[litellm] ## Database Configuration -In this guide, we will use an example SQLAlchemy database containing a single table named `candidates`. This table includes columns such as `id`, `name`, `country`, `years_of_experience`, `position`, `university`, `skills`, and `tags`. You can download the example database from [candidates.db](https://github.com/deepsense-ai/db-ally/tree/main/examples/recruting/candidates.db). Alternatively, you can use your own database and models. +In this guide, we will use an example SQLAlchemy database containing a single table named `candidates`. This table includes columns such as `id`, `name`, `country`, `years_of_experience`, `position`, `university`, `skills`, and `tags`. You can download the example database from [candidates.db](https://github.com/deepsense-ai/db-ally/tree/main/examples/recruiting/candidates.db). Alternatively, you can use your own database and models. To connect to the database using SQLAlchemy, you need an engine and your database models. Start by creating an engine: diff --git a/docs/quickstart/similarity_indexes/country_similarity.index b/docs/quickstart/similarity_indexes/country_similarity.index deleted file mode 100644 index d141a443d0adc9e324e12b5c50687388d619b5b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141357 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 F{tpcv&$R#m diff --git a/docs/quickstart/similarity_indexes/country_similarity.npy b/docs/quickstart/similarity_indexes/country_similarity.npy deleted file mode 100644 index e9b8f814a6a8de2d53b5c4bb20f5fe2f23c27642..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1232 zcmbV~PfNo<5XIN4pJH!O$i;dSFI8HpQYmT>XTVnt*d-_S5`$JG0Ycr(Eoa#uY%Tl zy~A`r_22&A*v*`C*J5A%3r;1<#bxs?ny)@vG16bO=pIG(#y~GC5gmH=#y~%F(X?Il z2hu)>A0p?h#TX_^mf}LRxtXO6V Date: Thu, 6 Jun 2024 20:44:56 +0200 Subject: [PATCH 17/25] update sqlite candidates engine string --- README.md | 2 +- docs/how-to/sql_views.md | 2 +- docs/how-to/use_elastic_vector_store_code.py | 2 +- docs/how-to/use_elasticsearch_store_code.py | 2 +- docs/quickstart/index.md | 2 +- docs/quickstart/quickstart2_code.py | 2 +- docs/quickstart/quickstart3_code.py | 2 +- docs/quickstart/quickstart_code.py | 5 ++++- 8 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a999d6fe..36e4bc9a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ class CandidateView(SqlAlchemyBaseView): """ return Candidate.country == country -engine = create_engine('sqlite:///candidates.db') +engine = create_engine('sqlite:///examples/recruiting/data/candidates.db') llm = LiteLLM(model_name="gpt-3.5-turbo") my_collection = create_collection("collection_name", llm) my_collection.add(CandidateView, lambda: CandidateView(engine)) diff --git a/docs/how-to/sql_views.md b/docs/how-to/sql_views.md index 85411180..75b39fb6 100644 --- a/docs/how-to/sql_views.md +++ b/docs/how-to/sql_views.md @@ -77,7 +77,7 @@ You need to connect to the database using SQLAlchemy before you can use your vie ```python from sqlalchemy import create_engine -engine = create_engine('sqlite:///candidates.db') +engine = create_engine('sqlite:///examples/recruiting/data/candidates.db') ``` ## Registering the view diff --git a/docs/how-to/use_elastic_vector_store_code.py b/docs/how-to/use_elastic_vector_store_code.py index c325fa2c..4817fcf4 100644 --- a/docs/how-to/use_elastic_vector_store_code.py +++ b/docs/how-to/use_elastic_vector_store_code.py @@ -17,7 +17,7 @@ from dbally.similarity.elastic_vector_search import ElasticVectorStore load_dotenv() -engine = create_engine("sqlite:///candidates.db") +engine = create_engine("sqlite:///examples/recruiting/data/candidates.db") Base = automap_base() diff --git a/docs/how-to/use_elasticsearch_store_code.py b/docs/how-to/use_elasticsearch_store_code.py index 39258c44..1f690c35 100644 --- a/docs/how-to/use_elasticsearch_store_code.py +++ b/docs/how-to/use_elasticsearch_store_code.py @@ -18,7 +18,7 @@ from dbally.similarity.elasticsearch_store import ElasticsearchStore load_dotenv() -engine = create_engine("sqlite:///candidates.db") +engine = create_engine("sqlite:///examples/recruiting/data/candidates.db") Base = automap_base() diff --git a/docs/quickstart/index.md b/docs/quickstart/index.md index b18e0ddd..9754fe08 100644 --- a/docs/quickstart/index.md +++ b/docs/quickstart/index.md @@ -37,7 +37,7 @@ To connect to the database using SQLAlchemy, you need an engine and your databas ```python from sqlalchemy import create_engine -engine = create_engine('sqlite:///candidates.db') +engine = create_engine('sqlite:///examples/recruiting/data/candidates.db') ``` Next, define an SQLAlchemy model for the `candidates` table. You can either declare the `Candidate` model using [declarative mapping](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#declarative-mapping) or generate it using [automap](https://docs.sqlalchemy.org/en/20/orm/extensions/automap.html). For simplicity, we'll use automap: diff --git a/docs/quickstart/quickstart2_code.py b/docs/quickstart/quickstart2_code.py index eab1e38e..593e7b4a 100644 --- a/docs/quickstart/quickstart2_code.py +++ b/docs/quickstart/quickstart2_code.py @@ -16,7 +16,7 @@ from dbally.llms.litellm import LiteLLM load_dotenv() -engine = create_engine("sqlite:///candidates.db") +engine = create_engine("sqlite:///examples/recruiting/data/candidates.db") Base = automap_base() Base.prepare(autoload_with=engine) diff --git a/docs/quickstart/quickstart3_code.py b/docs/quickstart/quickstart3_code.py index 2ef8f1df..f0c9270a 100644 --- a/docs/quickstart/quickstart3_code.py +++ b/docs/quickstart/quickstart3_code.py @@ -14,7 +14,7 @@ from dbally.embeddings.litellm import LiteLLMEmbeddingClient from dbally.llms.litellm import LiteLLM -engine = create_engine("sqlite:///candidates.db") +engine = create_engine("sqlite:///examples/recruiting/data/candidates.db") Base = automap_base() Base.prepare(autoload_with=engine) diff --git a/docs/quickstart/quickstart_code.py b/docs/quickstart/quickstart_code.py index 8cdbd446..34ee9765 100644 --- a/docs/quickstart/quickstart_code.py +++ b/docs/quickstart/quickstart_code.py @@ -11,17 +11,19 @@ from dbally.llms.litellm import LiteLLM -engine = create_engine('sqlite:///candidates.db') +engine = create_engine("sqlite:///examples/recruiting/data/candidates.db") Base = automap_base() Base.prepare(autoload_with=engine) Candidate = Base.classes.candidates + class CandidateView(SqlAlchemyBaseView): """ A view for retrieving candidates from the database. """ + def get_select(self) -> sqlalchemy.Select: """ Creates the initial SqlAlchemy select object, which will be used to build the query. @@ -52,6 +54,7 @@ def from_country(self, country: str) -> sqlalchemy.ColumnElement: """ return Candidate.country == country + async def main(): llm = LiteLLM(model_name="gpt-3.5-turbo") From 9642ff4f44f7b7db7e9a5b14f2edd451ea7d5fe7 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 7 Jun 2024 08:38:11 +0200 Subject: [PATCH 18/25] pylint fix --- examples/visualize_views_code.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/visualize_views_code.py b/examples/visualize_views_code.py index ad2d1d04..81571181 100644 --- a/examples/visualize_views_code.py +++ b/examples/visualize_views_code.py @@ -5,8 +5,8 @@ from dbally.audit import CLIEventHandler from dbally.gradio import create_gradio_interface from dbally.llms.litellm import LiteLLM -from examples.recruiting.candidate_view_with_similarity_store import CandidateView, country_similarity, engine -from examples.recruiting.cypher_text2sql_view import SampleText2SQLViewCyphers, create_freeform_memory_engine +from recruiting.candidate_view_with_similarity_store import CandidateView, country_similarity, engine +from recruiting.cypher_text2sql_view import SampleText2SQLViewCyphers, create_freeform_memory_engine async def main(): From fd0802028fa5608a1c2a4f4e799248d13645be87 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 7 Jun 2024 08:42:57 +0200 Subject: [PATCH 19/25] pylint fix --- examples/visualize_views_code.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/visualize_views_code.py b/examples/visualize_views_code.py index 81571181..847af9d3 100644 --- a/examples/visualize_views_code.py +++ b/examples/visualize_views_code.py @@ -1,12 +1,13 @@ # pylint: disable=missing-function-docstring import asyncio +from recruiting.candidate_view_with_similarity_store import CandidateView, country_similarity, engine +from recruiting.cypher_text2sql_view import SampleText2SQLViewCyphers, create_freeform_memory_engine + import dbally from dbally.audit import CLIEventHandler from dbally.gradio import create_gradio_interface from dbally.llms.litellm import LiteLLM -from recruiting.candidate_view_with_similarity_store import CandidateView, country_similarity, engine -from recruiting.cypher_text2sql_view import SampleText2SQLViewCyphers, create_freeform_memory_engine async def main(): From 7cc8818188870fa8e47abed9afa363a19a8a3800 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 7 Jun 2024 15:20:06 +0200 Subject: [PATCH 20/25] Michal review fiuxps --- docs/how-to/visualize_views.md | 7 +++---- examples/visualize_views_code.py | 3 +-- src/dbally/gradio/gradio_interface.py | 10 ++++------ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index 88efb375..82bf992e 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -1,6 +1,6 @@ # How-To: Visualize Views -To create simple UI interface use [GradioAdapter class](../../src/dbally/gradio/gradio_interface.py) It allows to display Data Preview related to Views +To create simple UI interface use [create_gradio_interface function](https://github.com/deepsense-ai/db-ally/tree/main/src/dbally/gradio/gradio_interface.py) It allows to display Data Preview related to Views and execute user queries. ## Installation @@ -31,12 +31,11 @@ gradio_interface = await create_gradio_interface(user_collection=collection) Launch the gradio interface. To publish public interface pass argument `share=True` ```python -if gradio_interface: - gradio_interface.launch() +gradio_interface.launch() ``` The endpoint is set by triggering python module with Gradio Adapter launch command. Private endpoint is set to http://127.0.0.1:7860/ by default. ## Links -* [Example Gradio Interface](https://github.com/deepsense-ai/db-ally/blob/lk/gradio_adapter/examples/visualize_views_code.py) \ No newline at end of file +* [Example Gradio Interface](https://github.com/deepsense-ai/db-ally/tree/main/examples/visualize_views_code.py) \ No newline at end of file diff --git a/examples/visualize_views_code.py b/examples/visualize_views_code.py index 847af9d3..31806fe8 100644 --- a/examples/visualize_views_code.py +++ b/examples/visualize_views_code.py @@ -17,8 +17,7 @@ async def main(): collection.add(CandidateView, lambda: CandidateView(engine)) collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) gradio_interface = await create_gradio_interface(user_collection=collection) - if gradio_interface: - gradio_interface.launch() + gradio_interface.launch() if __name__ == "__main__": diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index ee60141f..137798bd 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -11,7 +11,7 @@ from dbally.utils.errors import NoViewFoundError, UnsupportedQueryError -async def create_gradio_interface(user_collection: Collection, preview_limit: int = 20) -> Optional[gradio.Interface]: +async def create_gradio_interface(user_collection: Collection, preview_limit: int = 20) -> gradio.Interface: """Adapt and integrate data collection and query execution with Gradio interface components. Args: @@ -19,7 +19,7 @@ async def create_gradio_interface(user_collection: Collection, preview_limit: in preview_limit: The maximum number of preview data records to display. Default is 20. Returns: - The created Gradio interface, or None if no data is available to load. + The created Gradio interface. """ adapter = GradioAdapter() gradio_interface = await adapter.create_interface(user_collection, preview_limit) @@ -104,9 +104,7 @@ async def _ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Da log_content = self.log.read() return generated_query, data, log_content - async def create_interface( - self, user_collection: Collection, preview_limit: int = 20 - ) -> Optional[gradio.Interface]: + async def create_interface(self, user_collection: Collection, preview_limit: int = 20) -> gradio.Interface: """ Creates a Gradio interface for interacting with the user collection and similarity stores. @@ -115,7 +113,7 @@ async def create_interface( preview_limit: The maximum number of preview data records to display. Default is 20. Returns: - The created Gradio interface, or None if no data is available to load. + The created Gradio interface. """ self.preview_limit = preview_limit From 1db795ef55069ced69a292ce5067ad4e1a4a3a6b Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 7 Jun 2024 16:56:18 +0200 Subject: [PATCH 21/25] fixups --- examples/recruiting/cypher_text2sql_view.py | 6 +- src/dbally/gradio/gradio_interface.py | 63 ++++++++++++--------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/examples/recruiting/cypher_text2sql_view.py b/examples/recruiting/cypher_text2sql_view.py index 83810d20..fb76f7b5 100644 --- a/examples/recruiting/cypher_text2sql_view.py +++ b/examples/recruiting/cypher_text2sql_view.py @@ -1,4 +1,6 @@ # pylint: disable=missing-return-doc, missing-function-docstring, missing-class-docstring, missing-return-type-doc +from typing import List + import sqlalchemy from sqlalchemy import text @@ -6,7 +8,7 @@ class SampleText2SQLViewCyphers(BaseText2SQLView): - def get_tables(self): + def get_tables(self) -> List[TableConfig]: return [ TableConfig( name="security_specialists", @@ -20,7 +22,7 @@ def get_tables(self): ] -def create_freeform_memory_engine(): +def create_freeform_memory_engine() -> sqlalchemy.Engine: freeform_engine = sqlalchemy.create_engine("sqlite:///:memory:") statements = [ diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index 137798bd..ed33a6f5 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -11,12 +11,12 @@ from dbally.utils.errors import NoViewFoundError, UnsupportedQueryError -async def create_gradio_interface(user_collection: Collection, preview_limit: int = 20) -> gradio.Interface: +async def create_gradio_interface(user_collection: Collection, preview_limit: int = 10) -> gradio.Interface: """Adapt and integrate data collection and query execution with Gradio interface components. Args: user_collection: The user's collection to interact with. - preview_limit: The maximum number of preview data records to display. Default is 20. + preview_limit: The maximum number of preview data records to display. Default is 10. Returns: The created Gradio interface. @@ -40,7 +40,7 @@ def __init__(self): self.collection = None self.log = StringIO() - async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str, None, None, None, None]: + async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, None, None, None, None]: """ Asynchronously loads preview data for a selected view name. @@ -50,10 +50,10 @@ async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataF Returns: A tuple containing the preview dataframe, load status text, and four None values to clean gradio fields. """ - preview_dataframe, load_status_text = self._load_preview_data(selected_view_name) - return preview_dataframe, load_status_text, None, None, None, None + preview_dataframe = self._load_preview_data(selected_view_name) + return preview_dataframe, None, None, None, None - def _load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str]: + def _load_preview_data(self, selected_view_name: str) -> pd.DataFrame: """ Loads preview data for a selected view name. @@ -61,35 +61,40 @@ def _load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, str selected_view_name: The name of the selected view to load preview data for. Returns: - A tuple containing the preview dataframe and load status text. + A tuple containing the preview dataframe """ selected_view = self.collection.get(selected_view_name) - text_to_display = "No data preview available" if issubclass(type(selected_view), BaseStructuredView): selected_view_results = selected_view.execute() preview_dataframe = pd.DataFrame.from_records(selected_view_results.results).head(self.preview_limit) - text_to_display = "Data preview loaded" else: preview_dataframe = pd.DataFrame() - return preview_dataframe, text_to_display + return preview_dataframe - async def _ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.DataFrame], str]: + async def _ui_ask_query( + self, question_query: str, natural_language_flag: bool + ) -> Tuple[Dict, Optional[pd.DataFrame], str, str]: """ Asynchronously processes a query and returns the results. Args: - question_query (str): The query to process. + question_query: The query to process. + natural_language_flag: Flag to indicate if the natural language shall be returned Returns: A tuple containing the generated query context, the query results as a dataframe, and the log output. """ self.log.seek(0) self.log.truncate(0) + textual_response = "" try: - execution_result = await self.collection.ask(question=question_query) + execution_result = await self.collection.ask( + question=question_query, return_natural_response=natural_language_flag + ) generated_query = str(execution_result.context) data = pd.DataFrame.from_records(execution_result.results) + textual_response = str(execution_result.textual_response) if natural_language_flag else textual_response except UnsupportedQueryError: generated_query = {"Query": "unsupported"} data = pd.DataFrame() @@ -102,15 +107,15 @@ async def _ui_ask_query(self, question_query: str) -> Tuple[Dict, Optional[pd.Da finally: self.log.seek(0) log_content = self.log.read() - return generated_query, data, log_content + return generated_query, data, textual_response, log_content - async def create_interface(self, user_collection: Collection, preview_limit: int = 20) -> gradio.Interface: + async def create_interface(self, user_collection: Collection, preview_limit) -> gradio.Interface: """ Creates a Gradio interface for interacting with the user collection and similarity stores. Args: user_collection: The user's collection to interact with. - preview_limit: The maximum number of preview data records to display. Default is 20. + preview_limit: The maximum number of preview data records to display. Returns: The created Gradio interface. @@ -122,13 +127,12 @@ async def create_interface(self, user_collection: Collection, preview_limit: int default_selected_view_name = None data_preview_frame = pd.DataFrame() - data_preview_status = "No view available" question_interactive = False view_list = [*user_collection.list()] if view_list: default_selected_view_name = view_list[0] - data_preview_frame, data_preview_status = self._load_preview_data(view_list[0]) + data_preview_frame = self._load_preview_data(view_list[0]) question_interactive = True with gradio.Blocks() as demo: @@ -137,19 +141,23 @@ async def create_interface(self, user_collection: Collection, preview_limit: int view_dropdown = gradio.Dropdown( label="Data View preview", choices=view_list, value=default_selected_view_name ) + query = gradio.Text(label="Ask question", interactive=question_interactive) + query_button = gradio.Button("Ask db-ally", interactive=question_interactive) + natural_language_response_checkbox = gradio.Checkbox( + label="Return natural language answer", interactive=question_interactive + ) + with gradio.Column(): - data_preview_info = gradio.Text(label="Data preview", value=data_preview_status) + gradio.Label(show_label=False, value="PREVIEW") if not data_preview_frame.empty: loaded_data_frame = gradio.Dataframe(value=data_preview_frame, interactive=False) else: loaded_data_frame = gradio.Dataframe(interactive=False) + gradio.Label(show_label=False, value="RESULT") + query_sql_result = gradio.Text(label="Generated query context") + generated_natural_language_answer = gradio.Text(label="Generated answer in natural language:") + query_result_frame = gradio.Dataframe(interactive=False) - with gradio.Row(): - query = gradio.Text(label="Ask question", interactive=question_interactive) - query_button = gradio.Button("Proceed", interactive=question_interactive) - with gradio.Row(): - query_sql_result = gradio.Text(label="Generated query context") - query_result_frame = gradio.Dataframe(interactive=False) with gradio.Row(): log_console = gradio.Code(label="Logs", language="shell") @@ -158,7 +166,6 @@ async def create_interface(self, user_collection: Collection, preview_limit: int inputs=view_dropdown, outputs=[ loaded_data_frame, - data_preview_info, query, query_sql_result, query_result_frame, @@ -166,7 +173,9 @@ async def create_interface(self, user_collection: Collection, preview_limit: int ], ) query_button.click( - fn=self._ui_ask_query, inputs=[query], outputs=[query_sql_result, query_result_frame, log_console] + fn=self._ui_ask_query, + inputs=[query, natural_language_response_checkbox], + outputs=[query_sql_result, query_result_frame, generated_natural_language_answer, log_console], ) return demo From ff32cefc109d92c8027a87a591f7d03f9eeaa8af Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Fri, 7 Jun 2024 18:22:50 +0200 Subject: [PATCH 22/25] fiupxs --- docs/how-to/visualize_views.md | 3 +++ src/dbally/gradio/gradio_interface.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/how-to/visualize_views.md b/docs/how-to/visualize_views.md index 82bf992e..6553e07b 100644 --- a/docs/how-to/visualize_views.md +++ b/docs/how-to/visualize_views.md @@ -24,6 +24,9 @@ collection.add(CandidateView, lambda: CandidateView(engine)) collection.add(SampleText2SQLViewCyphers, lambda: SampleText2SQLViewCyphers(create_freeform_memory_engine())) ``` +>_**NOTE**_: The following code requires environment variables to proceed with LLM queries. For the example below, set the +> ```OPENAI_API_KEY``` environment variable. + Create gradio interface ```python gradio_interface = await create_gradio_interface(user_collection=collection) diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index ed33a6f5..e75cc5ec 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -109,7 +109,7 @@ async def _ui_ask_query( log_content = self.log.read() return generated_query, data, textual_response, log_content - async def create_interface(self, user_collection: Collection, preview_limit) -> gradio.Interface: + async def create_interface(self, user_collection: Collection, preview_limit: int) -> gradio.Interface: """ Creates a Gradio interface for interacting with the user collection and similarity stores. From 33163c51987bf6328a9b8442ea3d7ea53686cb23 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Mon, 10 Jun 2024 21:41:14 +0200 Subject: [PATCH 23/25] layout changes --- src/dbally/gradio/gradio_interface.py | 54 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index e75cc5ec..80947131 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -40,7 +40,7 @@ def __init__(self): self.collection = None self.log = StringIO() - async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataFrame, None, None, None, None]: + async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[gradio.DataFrame, None, None, None]: """ Asynchronously loads preview data for a selected view name. @@ -51,7 +51,7 @@ async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[pd.DataF A tuple containing the preview dataframe, load status text, and four None values to clean gradio fields. """ preview_dataframe = self._load_preview_data(selected_view_name) - return preview_dataframe, None, None, None, None + return gradio.DataFrame(label="Preview", value=preview_dataframe), None, None, None def _load_preview_data(self, selected_view_name: str) -> pd.DataFrame: """ @@ -74,7 +74,7 @@ def _load_preview_data(self, selected_view_name: str) -> pd.DataFrame: async def _ui_ask_query( self, question_query: str, natural_language_flag: bool - ) -> Tuple[Dict, Optional[pd.DataFrame], str, str]: + ) -> Tuple[gradio.Text, gradio.DataFrame, gradio.Text, str]: """ Asynchronously processes a query and returns the results. @@ -107,7 +107,15 @@ async def _ui_ask_query( finally: self.log.seek(0) log_content = self.log.read() - return generated_query, data, textual_response, log_content + return ( + gradio.Text(value=generated_query, visible=True), + gradio.DataFrame(label="Results", value=data), + gradio.Text(value=textual_response, visible=natural_language_flag), + log_content, + ) + + def _hide_results_fields(self) -> Tuple[gradio.Text, gradio.Text]: + return gradio.Text(visible=False), gradio.Text(visible=False) async def create_interface(self, user_collection: Collection, preview_limit: int) -> gradio.Interface: """ @@ -143,24 +151,45 @@ async def create_interface(self, user_collection: Collection, preview_limit: int ) query = gradio.Text(label="Ask question", interactive=question_interactive) query_button = gradio.Button("Ask db-ally", interactive=question_interactive) + clear_button = gradio.ClearButton(components=[query], interactive=question_interactive) natural_language_response_checkbox = gradio.Checkbox( label="Return natural language answer", interactive=question_interactive ) with gradio.Column(): - gradio.Label(show_label=False, value="PREVIEW") if not data_preview_frame.empty: - loaded_data_frame = gradio.Dataframe(value=data_preview_frame, interactive=False) + loaded_data_frame = gradio.Dataframe( + label="Preview", value=data_preview_frame, interactive=False + ) else: - loaded_data_frame = gradio.Dataframe(interactive=False) - gradio.Label(show_label=False, value="RESULT") - query_sql_result = gradio.Text(label="Generated query context") - generated_natural_language_answer = gradio.Text(label="Generated answer in natural language:") - query_result_frame = gradio.Dataframe(interactive=False) + loaded_data_frame = gradio.Dataframe(label="Preview not available", interactive=False) + query_sql_result = gradio.Text(label="Generated query context", visible=False) + generated_natural_language_answer = gradio.Text( + label="Generated answer in natural language:", visible=False + ) with gradio.Row(): log_console = gradio.Code(label="Logs", language="shell") + clear_button.add( + [ + natural_language_response_checkbox, + loaded_data_frame, + query_sql_result, + generated_natural_language_answer, + log_console, + ] + ) + + clear_button.click( + fn=self._hide_results_fields, + inputs=[], + outputs=[ + query_sql_result, + generated_natural_language_answer, + ], + ) + view_dropdown.change( fn=self._ui_load_preview_data, inputs=view_dropdown, @@ -168,14 +197,13 @@ async def create_interface(self, user_collection: Collection, preview_limit: int loaded_data_frame, query, query_sql_result, - query_result_frame, log_console, ], ) query_button.click( fn=self._ui_ask_query, inputs=[query, natural_language_response_checkbox], - outputs=[query_sql_result, query_result_frame, generated_natural_language_answer, log_console], + outputs=[query_sql_result, loaded_data_frame, generated_natural_language_answer, log_console], ) return demo From 386735e1907b7c7faa247704e8ea967b0b7e8801 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Mon, 10 Jun 2024 21:44:04 +0200 Subject: [PATCH 24/25] pylint fix --- src/dbally/gradio/gradio_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index 80947131..2cc66917 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -1,5 +1,5 @@ from io import StringIO -from typing import Dict, Optional, Tuple +from typing import Tuple import gradio import pandas as pd From 9003cf8f1a5cc5762862d3fadc6ddcfaf2e0bd16 Mon Sep 17 00:00:00 2001 From: Lukasz Karlowski Date: Tue, 11 Jun 2024 09:27:20 +0200 Subject: [PATCH 25/25] fixes --- src/dbally/gradio/gradio_interface.py | 66 +++++++++++++++++++++------ 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/src/dbally/gradio/gradio_interface.py b/src/dbally/gradio/gradio_interface.py index 2cc66917..a6669685 100644 --- a/src/dbally/gradio/gradio_interface.py +++ b/src/dbally/gradio/gradio_interface.py @@ -37,10 +37,25 @@ def __init__(self): """ self.preview_limit = None + self.selected_view_name = None self.collection = None self.log = StringIO() - async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[gradio.DataFrame, None, None, None]: + def _load_gradio_data(self, preview_dataframe, label, empty_warning=None) -> Tuple[gradio.DataFrame, gradio.Label]: + if not empty_warning: + empty_warning = "Preview not available" + + if preview_dataframe.empty: + gradio_preview_dataframe = gradio.DataFrame(label=label, value=preview_dataframe, visible=False) + empty_frame_label = gradio.Label(value=f"{label} not available", visible=True, show_label=False) + else: + gradio_preview_dataframe = gradio.DataFrame(label=label, value=preview_dataframe, visible=True) + empty_frame_label = gradio.Label(value=f"{label} not available", visible=False, show_label=False) + return gradio_preview_dataframe, empty_frame_label + + async def _ui_load_preview_data( + self, selected_view_name: str + ) -> Tuple[gradio.DataFrame, gradio.Label, None, None, None]: """ Asynchronously loads preview data for a selected view name. @@ -50,8 +65,11 @@ async def _ui_load_preview_data(self, selected_view_name: str) -> Tuple[gradio.D Returns: A tuple containing the preview dataframe, load status text, and four None values to clean gradio fields. """ + self.selected_view_name = selected_view_name preview_dataframe = self._load_preview_data(selected_view_name) - return gradio.DataFrame(label="Preview", value=preview_dataframe), None, None, None + gradio_preview_dataframe, empty_frame_label = self._load_gradio_data(preview_dataframe, "Preview") + + return gradio_preview_dataframe, empty_frame_label, None, None, None def _load_preview_data(self, selected_view_name: str) -> pd.DataFrame: """ @@ -74,7 +92,7 @@ def _load_preview_data(self, selected_view_name: str) -> pd.DataFrame: async def _ui_ask_query( self, question_query: str, natural_language_flag: bool - ) -> Tuple[gradio.Text, gradio.DataFrame, gradio.Text, str]: + ) -> Tuple[gradio.DataFrame, gradio.Label, gradio.Text, gradio.Text, str]: """ Asynchronously processes a query and returns the results. @@ -107,15 +125,26 @@ async def _ui_ask_query( finally: self.log.seek(0) log_content = self.log.read() + + gradio_dataframe, empty_dataframe_warning = self._load_gradio_data(data, "Results", "No matching results found") return ( + gradio_dataframe, + empty_dataframe_warning, gradio.Text(value=generated_query, visible=True), - gradio.DataFrame(label="Results", value=data), gradio.Text(value=textual_response, visible=natural_language_flag), log_content, ) - def _hide_results_fields(self) -> Tuple[gradio.Text, gradio.Text]: - return gradio.Text(visible=False), gradio.Text(visible=False) + def _clear_results(self) -> Tuple[gradio.DataFrame, gradio.Label, gradio.Text, gradio.Text]: + preview_dataframe = self._load_preview_data(self.selected_view_name) + gradio_preview_dataframe, empty_frame_label = self._load_gradio_data(preview_dataframe, "Preview") + + return ( + gradio_preview_dataframe, + empty_frame_label, + gradio.Text(visible=False), + gradio.Text(visible=False), + ) async def create_interface(self, user_collection: Collection, preview_limit: int) -> gradio.Interface: """ @@ -133,21 +162,20 @@ async def create_interface(self, user_collection: Collection, preview_limit: int self.collection = user_collection self.collection.add_event_handler(CLIEventHandler(self.log)) - default_selected_view_name = None data_preview_frame = pd.DataFrame() question_interactive = False view_list = [*user_collection.list()] if view_list: - default_selected_view_name = view_list[0] - data_preview_frame = self._load_preview_data(view_list[0]) + self.selected_view_name = view_list[0] + data_preview_frame = self._load_preview_data(self.selected_view_name) question_interactive = True with gradio.Blocks() as demo: with gradio.Row(): with gradio.Column(): view_dropdown = gradio.Dropdown( - label="Data View preview", choices=view_list, value=default_selected_view_name + label="Data View preview", choices=view_list, value=self.selected_view_name ) query = gradio.Text(label="Ask question", interactive=question_interactive) query_button = gradio.Button("Ask db-ally", interactive=question_interactive) @@ -161,8 +189,11 @@ async def create_interface(self, user_collection: Collection, preview_limit: int loaded_data_frame = gradio.Dataframe( label="Preview", value=data_preview_frame, interactive=False ) + empty_frame_label = gradio.Label(value="Preview not available", visible=False) else: - loaded_data_frame = gradio.Dataframe(label="Preview not available", interactive=False) + loaded_data_frame = gradio.Dataframe(interactive=False, visible=False) + empty_frame_label = gradio.Label(value="Preview not available", visible=True) + query_sql_result = gradio.Text(label="Generated query context", visible=False) generated_natural_language_answer = gradio.Text( label="Generated answer in natural language:", visible=False @@ -182,9 +213,11 @@ async def create_interface(self, user_collection: Collection, preview_limit: int ) clear_button.click( - fn=self._hide_results_fields, + fn=self._clear_results, inputs=[], outputs=[ + loaded_data_frame, + empty_frame_label, query_sql_result, generated_natural_language_answer, ], @@ -195,6 +228,7 @@ async def create_interface(self, user_collection: Collection, preview_limit: int inputs=view_dropdown, outputs=[ loaded_data_frame, + empty_frame_label, query, query_sql_result, log_console, @@ -203,7 +237,13 @@ async def create_interface(self, user_collection: Collection, preview_limit: int query_button.click( fn=self._ui_ask_query, inputs=[query, natural_language_response_checkbox], - outputs=[query_sql_result, loaded_data_frame, generated_natural_language_answer, log_console], + outputs=[ + loaded_data_frame, + empty_frame_label, + query_sql_result, + generated_natural_language_answer, + log_console, + ], ) return demo