I am trying to create an app where a user can ask a question from a predefined list of questions, get an answer, and ask another question. The user would see a history of their questions and answers. The answers would come from a SQL query mapped to the question, but that’s not relevant for the example shown here.
In my mind, the steps are:
- A user can type in a question or select a question from the list of predefined questions. If they select a question or type a question that completely matches one of the predefined questions, they would immediately see a list of drop downs for possible values in the double-quoted column names (that is, column names from tables in a SQL database). If they type the question, there is some type of text similarity to match the possible questions (for example, maybe they misspell a word), but that’s not super relevant for this question.
- Once a user selects values for all the double-quoted column name drop down menus, that would immediately start a SQL query in the background (which isn’t relevant for this question). The user should then see the question they asked with the answer, and be able to ask another question.
I have implemented these steps into an app that currently looks like this:
Here is the code for what you see in the gif above:
from dash import callback, Dash, dcc, html, Input, Output, ALL, ctx, State
from dash.exceptions import PreventUpdate
from difflib import get_close_matches
import dash_bootstrap_components as dbc
questions = ['Which division sells product number "product_number"?',
'What is the order amount of customer "customer_name"?',
'What is the order amount of customer "customer_name" in year "year"?',
'Customer "customer_name" bought how much of product number "product_number" in year "year"?']
unique_values = {'product_number': [0, 1, 2, 3],
'customer_name': ['Customer A', 'Customer B', 'Customer C', 'Customer D'],
'year': [2018, 2019, 2020, 2021]}
app = Dash(
__name__,
external_stylesheets=[dbc.themes.LUX]
)
def create_list_of_dropdown_menu_items(questions):
return [
dbc.DropdownMenuItem(
id={'type': 'selection', 'index': idx},
children=question
) for idx, question in enumerate(questions)
]
app.layout = html.Div(
[
html.Div(
[
dbc.DropdownMenu(
id='predefined-questions-dropdownmenu',
label=dcc.Input(
id='user-text-input',
type='text',
value='',
placeholder='Type question here...',
style={'width': '400px'}
),
children=create_list_of_dropdown_menu_items(questions)
),
],
id='changing-div'
),
html.Div(id='question-and-answer-div')
]
)
@callback(
Output('predefined-questions-dropdownmenu', 'children'),
Input('user-text-input', 'value'),
)
def provide_search_results(text):
# If there is text, check for matching questions based on text similarity
if text:
matches = get_close_matches(word=text, possibilities=questions, n=3, cutoff=0.6)
#print('-' * 10)
#print(text)
#print(matches)
# If there are matches, list those. Otherwise, list all questions
if matches:
return create_list_of_dropdown_menu_items(matches)
else:
return create_list_of_dropdown_menu_items(questions)
# List all questions in the dropdown when app first loads and if user completely erases the text
else:
return create_list_of_dropdown_menu_items(questions)
# If the user selects a question from the dropdown, the question should appear in the input
@callback(
Output('user-text-input', 'value'),
Input({'type': 'selection', 'index': ALL}, 'n_clicks'),
State({'type': 'selection', 'index': ALL}, 'children')
)
def update_input(clicks, options):
# Do nothing when app first loads
if all(click is None for click in clicks):
raise PreventUpdate
idx = ctx.triggered_id['index']
return options[idx]
@callback(
Output('changing-div', 'children'),
Output('question-and-answer-div', 'children'),
Input('user-text-input', 'value'),
Input({'type': 'filter-dropdown', 'index': ALL}, 'value'),
Input({'type': 'filter-dropdown', 'index': ALL}, 'id'),
State('changing-div', 'children'),
State('question-and-answer-div', 'children'))
def modify_changing_div(text, values, ids, children_changing_div, children_question_and_answer_div):
idx = ctx.triggered_id
#print('idx:', idx)
if idx == 'user-text-input':
# If the user selects a question from the dropdown or completely types out a question,
# then create dropdowns for the double-quoted column names in the question
if any(text == question for question in questions):
parts = text.split('"')
parts.remove('?')
non_column_names = [part for part in parts if
part.startswith(' ') or
part.endswith(' ')]
column_names = [part for part in parts if
not part.startswith(' ') and
not part.endswith(' ')]
output = []
for part in parts:
if part in non_column_names:
output.append(html.Div(part, style={'display': 'inline-block'}))
elif part in column_names:
output.append(html.Div(
dcc.Dropdown(
id={'type': 'filter-dropdown', 'index': part},
options=unique_values[part]
),
style={'display': 'inline-block', 'width': '10%'}))
else:
raise ValueError(f'Anomalous part: {part}')
output.append(html.Div('?', style={'display': 'inline-block'}))
# Need to store somewhere a (hidden) version of the question with the appropriate component name
# 1. The question text is needed in the outermost else statement of this function
# 2. If the component name doesn't exist, then there will be "A nonexistent object was used in an Input of a Dash callback."
output.append(html.Div(
dcc.Input(
id='user-text-input',
value=text,
readOnly=True,
disabled=True),
style={'display': 'none'}))
return output, children_question_and_answer_div
# Otherwise, return the current state
else:
return children_changing_div, children_question_and_answer_div
# Otherwise, the dropdowns for the double-quoted column names in the question have been created
else:
# If the user has selected a value for all dropdowns, then show the question history and allow them to ask another question
if all(value is not None for value in values):
changing_div = html.Div(
[
dbc.DropdownMenu(
id='predefined-questions-dropdownmenu',
label=dcc.Input(
id='user-text-input',
type='text',
value='',
placeholder='Type question here...',
style={'width': '400px'}
),
children=create_list_of_dropdown_menu_items(questions)
),
],
id='changing-div'
),
# Query the database with this question and add the answer to the text (not relevant for this minimal example)
for id, value in zip(ids, values):
text = text.replace('"' + id['index'] + '"', '"' + str(value) + '"')
if children_question_and_answer_div is None:
return changing_div, [html.Div(text)]
else:
children_question_and_answer_div.append(html.Div(text))
return changing_div, children_question_and_answer_div
# Otherwise, return the current state
else:
return children_changing_div, children_question_and_answer_div
if __name__ == '__main__':
app.run_server(debug=True)
I am wondering if there is a way to simplify the function modify_changing_div
or if there are any suggestions for good practices to follow when creating a function that can modify a html.Div
in multiple ways (that is, one part of the function converts the question to have drop down menus for the double-quoted column names, and another part of the function allows the user to ask another question).
I guess this is a general “Looking for any suggestions to improve my code” type of question. If this type of question is not appropriate for this forum, please let me know.