├── .gitignore ├── README.md ├── app.py ├── explain_plot.py ├── prompt.md ├── query.py ├── requirements.txt ├── shared.py ├── tips.csv └── www ├── stars.svg └── styles.css /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sidebot (Python Edition) 2 | 3 | This is a demonstration of using an LLM to enhance a data dashboard written in [Shiny](https://shiny.posit.co/py/). 4 | 5 | [**Live demo**](https://jcheng.shinyapps.io/sidebot) 6 | 7 | To run locally, you'll need to create an `.env` file in the repo root with `OPENAI_API_KEY=` followed by a valid OpenAI API key, and/or `ANTHROPIC_API_KEY=` if you want to use Claude. Or if those environment values are set some other way, you can skip the .env file. 8 | 9 | Then run: 10 | 11 | ```bash 12 | pip install -r requirements.txt 13 | shiny run --launch-browser 14 | ``` 15 | 16 | ## Warnings and limitations 17 | 18 | This app sends at least your data schema to a remote LLM. As written, it also permits the LLM to run SQL queries against your data and get the results back. Please keep these facts in mind when dealing with sensitive data. 19 | 20 | ## Other versions 21 | 22 | You can find the R version of this app at [https://github.com/jcheng5/r-sidebot](https://github.com/jcheng5/r-sidebot). -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from pathlib import Path 3 | from typing import Annotated 4 | 5 | import dotenv 6 | import duckdb 7 | import faicons as fa 8 | import plotly.express as px 9 | from chatlas import ChatAnthropic, ChatOpenAI 10 | from shiny import App, reactive, render, ui 11 | from shinywidgets import output_widget, render_plotly 12 | 13 | dotenv.load_dotenv() 14 | 15 | import query 16 | from explain_plot import explain_plot 17 | from shared import tips # Load data and compute static values 18 | 19 | here = Path(__file__).parent 20 | 21 | greeting = """ 22 | You can use this sidebar to filter and sort the data based on the columns available in the `tips` table. Here are some examples of the kinds of questions you can ask me: 23 | 24 | 1. Filtering: Show only Male smokers who had Dinner on Saturday. 25 | 2. Sorting: Show all data sorted by total_bill in descending order. 26 | 3. Answer questions about the data: How do tip sizes compare between lunch and dinner? 27 | 28 | You can also say Reset to clear the current filter/sort, or Help for more usage tips. 29 | """ 30 | 31 | # Set to True to greatly enlarge chat UI (for presenting to a larger audience) 32 | DEMO_MODE = False 33 | 34 | icon_ellipsis = fa.icon_svg("ellipsis") 35 | icon_explain = ui.img(src="stars.svg") 36 | 37 | app_ui = ui.page_sidebar( 38 | ui.sidebar( 39 | ui.chat_ui( 40 | "chat", height="100%", style=None if not DEMO_MODE else "zoom: 1.6;" 41 | ), 42 | open="desktop", 43 | width=400 if not DEMO_MODE else "50%", 44 | style="height: 100%;", 45 | gap="3px", 46 | ), 47 | ui.tags.link(rel="stylesheet", href="styles.css"), 48 | # 49 | # 🏷️ Header 50 | # 51 | ui.output_text("show_title", container=ui.h3), 52 | ui.output_code("show_query", placeholder=False).add_style( 53 | "max-height: 100px; overflow: auto;" 54 | ), 55 | # 56 | # 🎯 Value boxes 57 | # 58 | ui.layout_columns( 59 | ui.value_box( 60 | "Total tippers", 61 | ui.output_text("total_tippers"), 62 | showcase=fa.icon_svg("user", "regular"), 63 | ), 64 | ui.value_box( 65 | "Average tip", ui.output_text("average_tip"), showcase=fa.icon_svg("wallet") 66 | ), 67 | ui.value_box( 68 | "Average bill", 69 | ui.output_text("average_bill"), 70 | showcase=fa.icon_svg("dollar-sign"), 71 | ), 72 | fill=False, 73 | ), 74 | ui.layout_columns( 75 | # 76 | # 🔍 Data table 77 | # 78 | ui.card( 79 | ui.card_header("Tips data"), 80 | ui.output_data_frame("table"), 81 | full_screen=True, 82 | ), 83 | # 84 | # 📊 Scatter plot 85 | # 86 | ui.card( 87 | ui.card_header( 88 | "Total bill vs. tip", 89 | ui.span( 90 | ui.input_action_link( 91 | "interpret_scatter", 92 | icon_explain, 93 | class_="me-3", 94 | style="color: inherit;", 95 | aria_label="Explain scatter plot", 96 | ), 97 | ui.popover( 98 | icon_ellipsis, 99 | ui.input_radio_buttons( 100 | "scatter_color", 101 | None, 102 | ["none", "sex", "smoker", "day", "time"], 103 | inline=True, 104 | ), 105 | title="Add a color variable", 106 | placement="top", 107 | ), 108 | ), 109 | class_="d-flex justify-content-between align-items-center", 110 | ), 111 | output_widget("scatterplot"), 112 | full_screen=True, 113 | ), 114 | # 115 | # 📊 Ridge plot 116 | # 117 | ui.card( 118 | ui.card_header( 119 | "Tip percentages", 120 | ui.span( 121 | ui.input_action_link( 122 | "interpret_ridge", 123 | icon_explain, 124 | class_="me-3", 125 | style="color: inherit;", 126 | aria_label="Explain ridgeplot", 127 | ), 128 | ui.popover( 129 | icon_ellipsis, 130 | ui.input_radio_buttons( 131 | "tip_perc_y", 132 | None, 133 | ["sex", "smoker", "day", "time"], 134 | selected="day", 135 | inline=True, 136 | ), 137 | title="Split by", 138 | ), 139 | ), 140 | class_="d-flex justify-content-between align-items-center", 141 | ), 142 | output_widget("tip_perc"), 143 | full_screen=True, 144 | ), 145 | col_widths=[6, 6, 12], 146 | ), 147 | title="Restaurant tipping", 148 | fillable=True, 149 | ) 150 | 151 | 152 | def server(input, output, session): 153 | 154 | # 155 | # 🔄 Reactive state/computation -------------------------------------------- 156 | # 157 | 158 | current_query = reactive.Value("") 159 | current_title = reactive.Value("") 160 | 161 | @reactive.calc 162 | def tips_data(): 163 | if current_query() == "": 164 | return tips 165 | return duckdb.query(current_query()).df() 166 | 167 | # 168 | # 🏷️ Header outputs -------------------------------------------------------- 169 | # 170 | 171 | @render.text 172 | def show_title(): 173 | return current_title() 174 | 175 | @render.text 176 | def show_query(): 177 | return current_query() 178 | 179 | # 180 | # 🎯 Value box outputs ----------------------------------------------------- 181 | # 182 | 183 | @render.text 184 | def total_tippers(): 185 | return str(tips_data().shape[0]) 186 | 187 | @render.text 188 | def average_tip(): 189 | d = tips_data() 190 | if d.shape[0] > 0: 191 | perc = d.tip / d.total_bill 192 | return f"{perc.mean():.1%}" 193 | 194 | @render.text 195 | def average_bill(): 196 | d = tips_data() 197 | if d.shape[0] > 0: 198 | bill = d.total_bill.mean() 199 | return f"${bill:.2f}" 200 | 201 | # 202 | # 🔍 Data table ------------------------------------------------------------ 203 | # 204 | 205 | @render.data_frame 206 | def table(): 207 | return render.DataGrid(tips_data()) 208 | 209 | # 210 | # 📊 Scatter plot ---------------------------------------------------------- 211 | # 212 | 213 | @render_plotly 214 | def scatterplot(): 215 | color = input.scatter_color() 216 | return px.scatter( 217 | tips_data(), 218 | x="total_bill", 219 | y="tip", 220 | color=None if color == "none" else color, 221 | trendline="lowess", 222 | ) 223 | 224 | @reactive.effect 225 | @reactive.event(input.interpret_scatter) 226 | async def interpret_scatter(): 227 | await explain_plot(fork_session(), scatterplot.widget) 228 | 229 | # 230 | # 📊 Ridge plot ------------------------------------------------------------ 231 | # 232 | 233 | @render_plotly 234 | def tip_perc(): 235 | from ridgeplot import ridgeplot 236 | 237 | dat = tips_data() 238 | yvar = input.tip_perc_y() 239 | uvals = dat[yvar].unique() 240 | 241 | samples = [[dat.percent[dat[yvar] == val]] for val in uvals] 242 | 243 | plt = ridgeplot( 244 | samples=samples, 245 | labels=uvals, 246 | bandwidth=0.01, 247 | colorscale="viridis", 248 | # Prevent a divide-by-zero error that row-index is susceptible to 249 | colormode="row-index" if len(uvals) > 1 else "mean-minmax", 250 | ) 251 | 252 | plt.update_layout( 253 | legend=dict( 254 | orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5 255 | ) 256 | ) 257 | 258 | return plt 259 | 260 | @reactive.effect 261 | @reactive.event(input.interpret_ridge) 262 | async def interpret_ridge(): 263 | await explain_plot(fork_session(), tip_perc.widget) 264 | 265 | # 266 | # ✨ Sidebot ✨ ------------------------------------------------------------- 267 | # 268 | 269 | Chat = ChatAnthropic 270 | chat_model = "claude-3-7-sonnet-latest" 271 | # Chat = ChatOpenAI 272 | # chat_model = "o1" 273 | chat_session = Chat( 274 | system_prompt=query.system_prompt(tips, "tips"), model=chat_model 275 | ) 276 | print(chat_session.system_prompt) 277 | 278 | def fork_session(): 279 | """ 280 | Fork the current chat session into a new one. This is useful to create a new 281 | chat session that is a copy of the current one. The new session has the same 282 | system prompt and model as the current one, and it has all the turns of the 283 | current session. The main reason to do this is to continue the conversation 284 | on a branch, without affecting the existing session. 285 | TODO: chatlas Chat objects really should have a copy() method 286 | 287 | Returns: 288 | A new Chat object which is a fork of the current session. 289 | """ 290 | new_session = Chat(system_prompt=chat_session.system_prompt, model=chat_model) 291 | new_session.register_tool(update_dashboard) 292 | new_session.register_tool(query_db) 293 | new_session.set_turns(chat_session.get_turns()) 294 | return new_session 295 | 296 | chat = ui.Chat("chat", messages=[greeting]) 297 | 298 | @chat.on_user_submit 299 | async def perform_chat(user_input: str): 300 | try: 301 | stream = await chat_session.stream_async(user_input, echo="all") 302 | except Exception as e: 303 | traceback.print_exc() 304 | return await chat.append_message(f"**Error**: {e}") 305 | 306 | await chat.append_message_stream(stream) 307 | 308 | async def update_filter(query, title): 309 | # Need this reactive lock/flush because we're going to call this from a 310 | # background asyncio task 311 | async with reactive.lock(): 312 | current_query.set(query) 313 | current_title.set(title) 314 | await reactive.flush() 315 | 316 | async def update_dashboard( 317 | query: Annotated[str, 'A DuckDB SQL query; must be a SELECT statement, or "".'], 318 | title: Annotated[ 319 | str, 320 | "A title to display at the top of the data dashboard, summarizing the intent of the SQL query.", 321 | ], 322 | ): 323 | """Modifies the data presented in the data dashboard, based on the given SQL query, and also updates the title.""" 324 | 325 | # Verify that the query is OK; throws if not 326 | if query != "": 327 | await query_db(query) 328 | 329 | await update_filter(query, title) 330 | 331 | async def query_db( 332 | query: Annotated[str, "A DuckDB SQL query; must be a SELECT statement."] 333 | ): 334 | """Perform a SQL query on the data, and return the results as JSON.""" 335 | return duckdb.query(query).to_df().to_json(orient="records") 336 | 337 | chat_session.register_tool(update_dashboard) 338 | chat_session.register_tool(query_db) 339 | 340 | 341 | app = App(app_ui, server, static_assets=here / "www") 342 | -------------------------------------------------------------------------------- /explain_plot.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import tempfile 3 | 4 | import chatlas 5 | import plotly.graph_objects as go 6 | from shiny import ui 7 | 8 | INSTRUCTIONS = """ 9 | Interpret this plot, which is based on the current state of the data (i.e. with 10 | filtering applied, if any). Try to make specific observations if you can, but 11 | be conservative in drawing firm conclusions and express uncertainty if you 12 | can't be confident. 13 | """.strip() 14 | 15 | counter = 0 # Never re-use the same chat ID 16 | 17 | 18 | async def explain_plot( 19 | chat_session: chatlas.Chat, 20 | plot_widget: go.FigureWidget, 21 | ) -> None: 22 | try: 23 | with tempfile.TemporaryFile() as f: 24 | plot_widget.write_image(f) 25 | f.seek(0) 26 | img_b64 = base64.b64encode(f.read()).decode("utf-8") 27 | img_url = f"data:image/png;base64,{img_b64}" 28 | 29 | global counter 30 | counter += 1 31 | chat_id = f"explain_plot_chat_{counter}" 32 | chat = ui.Chat(id=chat_id) 33 | 34 | # TODO: Call chat.destroy() when the modal is dismissed? 35 | dialog = make_modal_dialog(img_url, ui.chat_ui(id=chat_id, height="100%")) 36 | ui.modal_show(dialog) 37 | 38 | async def ask(*user_prompt: str | chatlas.types.Content): 39 | resp = await chat_session.stream_async(*user_prompt) 40 | await chat.append_message_stream(resp) 41 | 42 | # Ask the initial question 43 | await ask(INSTRUCTIONS, chatlas.content_image_url(img_url)) 44 | 45 | # Allow followup questions 46 | @chat.on_user_submit 47 | async def on_user_submit(user_input: str): 48 | await ask(user_input) 49 | 50 | except Exception as e: 51 | import traceback 52 | 53 | traceback.print_exc() 54 | ui.notification_show(str(e), type="error") 55 | 56 | 57 | def make_modal_dialog(img_url, chat_ui): 58 | return ui.modal( 59 | ui.tags.button( 60 | type="button", 61 | class_="btn-close d-block ms-auto mb-3", 62 | data_bs_dismiss="modal", 63 | aria_label="Close", 64 | ), 65 | ui.img( 66 | src=img_url, 67 | style="max-width: min(100%, 500px);", 68 | class_="d-block border mx-auto mb-3", 69 | ), 70 | ui.div( 71 | chat_ui, 72 | style="overflow-y: auto; max-height: min(60vh, 600px);", 73 | ), 74 | size="l", 75 | easy_close=True, 76 | title=None, 77 | footer=None, 78 | ).add_style("--bs-modal-margin: 1.75rem;") 79 | -------------------------------------------------------------------------------- /prompt.md: -------------------------------------------------------------------------------- 1 | You are a chatbot that is displayed in the sidebar of a data dashboard. You will be asked to perform various tasks on the data, such as filtering, sorting, and answering questions. 2 | 3 | It's important that you get clear, unambiguous instructions from the user, so if the user's request is unclear in any way, you should ask for clarification. If you aren't sure how to accomplish the user's request, say so, rather than using an uncertain technique. 4 | 5 | The user interface in which this conversation is being shown is a narrow sidebar of a dashboard, so keep your answers concise and don't include unnecessary patter, nor additional prompts or offers for further assistance. 6 | 7 | You have at your disposal a DuckDB database containing this schema: 8 | 9 | ${SCHEMA} 10 | 11 | For security reasons, you may only query this specific table. 12 | 13 | There are several tasks you may be asked to do: 14 | 15 | ## Task: Filtering and sorting 16 | 17 | The user may ask you to perform filtering and sorting operations on the dashboard; if so, your job is to write the appropriate SQL query for this database. Then, call the tool `update_dashboard`, passing in the SQL query and a new title summarizing the query (suitable for displaying at the top of dashboard). This tool will not provide a return value; it will filter the dashboard as a side-effect, so you can treat a null tool response as success. 18 | 19 | * **Call `update_dashboard` every single time** the user wants to filter/sort; never tell the user you've updated the dashboard unless you've called `update_dashboard` and it returned without error. 20 | * The SQL query must be a **DuckDB SQL** SELECT query. You may use any SQL functions supported by DuckDB, including subqueries, CTEs, and statistical functions. 21 | * The user may ask to "reset" or "start over"; that means clearing the filter and title. Do this by calling `update_dashboard({"query": "", "title": ""})`, and if it succeeds, tell the user what you've done. 22 | * Queries passed to `update_dashboard` MUST always **return all columns that are in the schema** (feel free to use `SELECT *`); you must refuse the request if this requirement cannot be honored, as the downstream code that will read the queried data will not know how to display it. You may add additional columns if necessary, but the existing columns must not be removed. 23 | * When calling `update_dashboard`, **don't describe the query itself** unless the user asks you to explain. Don't pretend you have access to the resulting data set, as you don't. 24 | 25 | For reproducibility, follow these rules as well: 26 | 27 | * Either the content that comes with `update_dashboard` or the final response MUST **include the SQL query itself**; this query must match the query that was passed to `update_dashboard` exactly, except word wrapped to a pretty narrow (40 character) width. This is CRUCIAL for reproducibility--do not miss this step. 28 | * Optimize the SQL query for **readability over efficiency**. 29 | * Always filter/sort with a **single SQL query** that can be passed directly to `update_dashboard`, even if that SQL query is very complicated. It's fine to use subqueries and common table expressions. 30 | * In particular, you MUST NOT use the `query` tool to retrieve data and then form your filtering SQL SELECT query based on that data. This would harm reproducibility because any intermediate SQL queries will not be preserved, only the final one that's passed to `update_dashboard`. 31 | * To filter based on standard deviations, percentiles, or quantiles, use a common table expression (WITH) to calculate the stddev/percentile/quartile that is needed to create the proper WHERE clause. 32 | * Include comments in the SQL to explain what each part of the query does. 33 | 34 | Example of filtering and sorting: 35 | 36 | > [User] 37 | > Show only rows where the value of x is greater than average. 38 | > [/User] 39 | > 40 | > [Assistant] 41 | > I've filtered the dashboard to show only rows where the value of x is greater than average. 42 | > 43 | > ```sql 44 | > SELECT * FROM table 45 | > WHERE x > (SELECT AVG(x) FROM table) 46 | > ``` 47 | > [/Assistant] 48 | 49 | ## Task: Answering questions about the data 50 | 51 | The user may ask you questions about the data. You have a `query` tool available to you that can be used to perform a SQL query on the data. 52 | 53 | The response should not only contain the answer to the question, but also, a comprehensive explanation of how you came up with the answer. The exact SQL queries you used (if any) must always be shown to the user, either in the content that comes with the tool call or in the final response. 54 | 55 | Also, always show the results of each SQL query, in a Markdown table. For results that are longer than 10 rows, only show the first 5 rows. 56 | 57 | Example of question answering: 58 | 59 | > [User] 60 | > What are the average values of x and y? 61 | > [/User] 62 | > 63 | > [Assistant] 64 | > The average value of x is 3.14. The average value of y is 6.28. 65 | > 66 | > I used the following SQL query to calculate this: 67 | > 68 | > ```sql 69 | > SELECT AVG(x) AS average_x 70 | > FROM table 71 | > ``` 72 | > 73 | > | average_x | average_y | 74 | > |----------:|----------:| 75 | > | 3.14 | 6.28 | 76 | > 77 | > [/Assistant] 78 | 79 | ## Task: Providing general help 80 | 81 | If the user provides a vague help request, like "Help" or "Show me instructions", describe your own capabilities in a helpful way, including offering input suggestions when relevant. Be sure to mention whatever advanced statistical capabilities (standard deviation, quantiles, correlation, variance) you have. 82 | 83 | Also, when offering input suggestions, note that you can wrap the text of each prompt in `` tags to make it clear that the user can click on it to use it as input. 84 | For example: 85 | 86 | Suggestions: 87 | 88 | 1. `Remove outliers from the dataset.` 89 | 2. `Filter the data to the particular value.` 90 | 3. `Reset the dashboard.` 91 | 92 | ## DuckDB SQL tips 93 | 94 | * `percentile_cont` and `percentile_disc` are "ordered set" aggregate functions. These functions are specified using the WITHIN GROUP (ORDER BY sort_expression) syntax, and they are converted to an equivalent aggregate function that takes the ordering expression as the first argument. For example, `percentile_cont(fraction) WITHIN GROUP (ORDER BY column [(ASC|DESC)])` is equivalent to `quantile_cont(column, fraction ORDER BY column [(ASC|DESC)])`. -------------------------------------------------------------------------------- /query.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pandas as pd 6 | 7 | # Available models: 8 | # 9 | # gpt-4o-mini (recommended) 10 | # gpt-4o 11 | # claude-3-5-sonnet-20240620 (recommended) 12 | # Llama3-8b-8192 13 | # Llama-3.1-8b-Instant 14 | # Llama-3.1-70b-Versatile 15 | # Mixtral-8x7b-32768 16 | 17 | default_model = "o3-mini" 18 | 19 | 20 | def system_prompt(df: pd.DataFrame, name: str, categorical_threshold: int = 10) -> str: 21 | schema = df_to_schema(df, name, categorical_threshold) 22 | with open(Path(__file__).parent / "prompt.md", "r") as f: 23 | rendered_prompt = f.read().replace("${SCHEMA}", schema) 24 | return rendered_prompt 25 | 26 | 27 | def df_to_schema(df: pd.DataFrame, name: str, categorical_threshold: int): 28 | schema = [] 29 | schema.append(f"Table: {name}") 30 | schema.append("Columns:") 31 | 32 | for column, dtype in df.dtypes.items(): 33 | # Map pandas dtypes to SQL-like types 34 | if pd.api.types.is_integer_dtype(dtype): 35 | sql_type = "INTEGER" 36 | elif pd.api.types.is_float_dtype(dtype): 37 | sql_type = "FLOAT" 38 | elif pd.api.types.is_bool_dtype(dtype): 39 | sql_type = "BOOLEAN" 40 | elif pd.api.types.is_datetime64_any_dtype(dtype): 41 | sql_type = "DATETIME" 42 | else: 43 | sql_type = "TEXT" 44 | 45 | schema.append(f"- {column} ({sql_type})") 46 | 47 | # For TEXT columns, check if they're categorical 48 | if sql_type == "TEXT": 49 | unique_values = df[column].nunique() 50 | if unique_values <= categorical_threshold: 51 | categories = df[column].unique().tolist() 52 | categories_str = ", ".join(f"'{cat}'" for cat in categories) 53 | schema.append(f" Categorical values: {categories_str}") 54 | # For FLOAT and INTEGER columns, add the range 55 | elif sql_type in ["INTEGER", "FLOAT"]: 56 | min_val = df[column].min() 57 | max_val = df[column].max() 58 | schema.append(f" Range: {min_val} to {max_val}") 59 | 60 | return "\n".join(schema) 61 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | faicons 2 | shiny>=1.3.0 3 | shinywidgets 4 | plotly 5 | pandas 6 | ridgeplot 7 | python-dotenv 8 | duckdb 9 | kaleido # Needed for plotly write_image 10 | chatlas 11 | anthropic 12 | openai -------------------------------------------------------------------------------- /shared.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import duckdb 4 | import pandas as pd 5 | 6 | duckdb.query("SET allow_community_extensions = false;") 7 | 8 | here = Path(__file__).parent 9 | tips = pd.read_csv(here / "tips.csv") 10 | tips["percent"] = tips.tip / tips.total_bill 11 | 12 | duckdb.register("tips", tips) 13 | -------------------------------------------------------------------------------- /tips.csv: -------------------------------------------------------------------------------- 1 | total_bill,tip,sex,smoker,day,time,size 2 | 16.99,1.01,Female,No,Sun,Dinner,2 3 | 10.34,1.66,Male,No,Sun,Dinner,3 4 | 21.01,3.5,Male,No,Sun,Dinner,3 5 | 23.68,3.31,Male,No,Sun,Dinner,2 6 | 24.59,3.61,Female,No,Sun,Dinner,4 7 | 25.29,4.71,Male,No,Sun,Dinner,4 8 | 8.77,2.0,Male,No,Sun,Dinner,2 9 | 26.88,3.12,Male,No,Sun,Dinner,4 10 | 15.04,1.96,Male,No,Sun,Dinner,2 11 | 14.78,3.23,Male,No,Sun,Dinner,2 12 | 10.27,1.71,Male,No,Sun,Dinner,2 13 | 35.26,5.0,Female,No,Sun,Dinner,4 14 | 15.42,1.57,Male,No,Sun,Dinner,2 15 | 18.43,3.0,Male,No,Sun,Dinner,4 16 | 14.83,3.02,Female,No,Sun,Dinner,2 17 | 21.58,3.92,Male,No,Sun,Dinner,2 18 | 10.33,1.67,Female,No,Sun,Dinner,3 19 | 16.29,3.71,Male,No,Sun,Dinner,3 20 | 16.97,3.5,Female,No,Sun,Dinner,3 21 | 20.65,3.35,Male,No,Sat,Dinner,3 22 | 17.92,4.08,Male,No,Sat,Dinner,2 23 | 20.29,2.75,Female,No,Sat,Dinner,2 24 | 15.77,2.23,Female,No,Sat,Dinner,2 25 | 39.42,7.58,Male,No,Sat,Dinner,4 26 | 19.82,3.18,Male,No,Sat,Dinner,2 27 | 17.81,2.34,Male,No,Sat,Dinner,4 28 | 13.37,2.0,Male,No,Sat,Dinner,2 29 | 12.69,2.0,Male,No,Sat,Dinner,2 30 | 21.7,4.3,Male,No,Sat,Dinner,2 31 | 19.65,3.0,Female,No,Sat,Dinner,2 32 | 9.55,1.45,Male,No,Sat,Dinner,2 33 | 18.35,2.5,Male,No,Sat,Dinner,4 34 | 15.06,3.0,Female,No,Sat,Dinner,2 35 | 20.69,2.45,Female,No,Sat,Dinner,4 36 | 17.78,3.27,Male,No,Sat,Dinner,2 37 | 24.06,3.6,Male,No,Sat,Dinner,3 38 | 16.31,2.0,Male,No,Sat,Dinner,3 39 | 16.93,3.07,Female,No,Sat,Dinner,3 40 | 18.69,2.31,Male,No,Sat,Dinner,3 41 | 31.27,5.0,Male,No,Sat,Dinner,3 42 | 16.04,2.24,Male,No,Sat,Dinner,3 43 | 17.46,2.54,Male,No,Sun,Dinner,2 44 | 13.94,3.06,Male,No,Sun,Dinner,2 45 | 9.68,1.32,Male,No,Sun,Dinner,2 46 | 30.4,5.6,Male,No,Sun,Dinner,4 47 | 18.29,3.0,Male,No,Sun,Dinner,2 48 | 22.23,5.0,Male,No,Sun,Dinner,2 49 | 32.4,6.0,Male,No,Sun,Dinner,4 50 | 28.55,2.05,Male,No,Sun,Dinner,3 51 | 18.04,3.0,Male,No,Sun,Dinner,2 52 | 12.54,2.5,Male,No,Sun,Dinner,2 53 | 10.29,2.6,Female,No,Sun,Dinner,2 54 | 34.81,5.2,Female,No,Sun,Dinner,4 55 | 9.94,1.56,Male,No,Sun,Dinner,2 56 | 25.56,4.34,Male,No,Sun,Dinner,4 57 | 19.49,3.51,Male,No,Sun,Dinner,2 58 | 38.01,3.0,Male,Yes,Sat,Dinner,4 59 | 26.41,1.5,Female,No,Sat,Dinner,2 60 | 11.24,1.76,Male,Yes,Sat,Dinner,2 61 | 48.27,6.73,Male,No,Sat,Dinner,4 62 | 20.29,3.21,Male,Yes,Sat,Dinner,2 63 | 13.81,2.0,Male,Yes,Sat,Dinner,2 64 | 11.02,1.98,Male,Yes,Sat,Dinner,2 65 | 18.29,3.76,Male,Yes,Sat,Dinner,4 66 | 17.59,2.64,Male,No,Sat,Dinner,3 67 | 20.08,3.15,Male,No,Sat,Dinner,3 68 | 16.45,2.47,Female,No,Sat,Dinner,2 69 | 3.07,1.0,Female,Yes,Sat,Dinner,1 70 | 20.23,2.01,Male,No,Sat,Dinner,2 71 | 15.01,2.09,Male,Yes,Sat,Dinner,2 72 | 12.02,1.97,Male,No,Sat,Dinner,2 73 | 17.07,3.0,Female,No,Sat,Dinner,3 74 | 26.86,3.14,Female,Yes,Sat,Dinner,2 75 | 25.28,5.0,Female,Yes,Sat,Dinner,2 76 | 14.73,2.2,Female,No,Sat,Dinner,2 77 | 10.51,1.25,Male,No,Sat,Dinner,2 78 | 17.92,3.08,Male,Yes,Sat,Dinner,2 79 | 27.2,4.0,Male,No,Thur,Lunch,4 80 | 22.76,3.0,Male,No,Thur,Lunch,2 81 | 17.29,2.71,Male,No,Thur,Lunch,2 82 | 19.44,3.0,Male,Yes,Thur,Lunch,2 83 | 16.66,3.4,Male,No,Thur,Lunch,2 84 | 10.07,1.83,Female,No,Thur,Lunch,1 85 | 32.68,5.0,Male,Yes,Thur,Lunch,2 86 | 15.98,2.03,Male,No,Thur,Lunch,2 87 | 34.83,5.17,Female,No,Thur,Lunch,4 88 | 13.03,2.0,Male,No,Thur,Lunch,2 89 | 18.28,4.0,Male,No,Thur,Lunch,2 90 | 24.71,5.85,Male,No,Thur,Lunch,2 91 | 21.16,3.0,Male,No,Thur,Lunch,2 92 | 28.97,3.0,Male,Yes,Fri,Dinner,2 93 | 22.49,3.5,Male,No,Fri,Dinner,2 94 | 5.75,1.0,Female,Yes,Fri,Dinner,2 95 | 16.32,4.3,Female,Yes,Fri,Dinner,2 96 | 22.75,3.25,Female,No,Fri,Dinner,2 97 | 40.17,4.73,Male,Yes,Fri,Dinner,4 98 | 27.28,4.0,Male,Yes,Fri,Dinner,2 99 | 12.03,1.5,Male,Yes,Fri,Dinner,2 100 | 21.01,3.0,Male,Yes,Fri,Dinner,2 101 | 12.46,1.5,Male,No,Fri,Dinner,2 102 | 11.35,2.5,Female,Yes,Fri,Dinner,2 103 | 15.38,3.0,Female,Yes,Fri,Dinner,2 104 | 44.3,2.5,Female,Yes,Sat,Dinner,3 105 | 22.42,3.48,Female,Yes,Sat,Dinner,2 106 | 20.92,4.08,Female,No,Sat,Dinner,2 107 | 15.36,1.64,Male,Yes,Sat,Dinner,2 108 | 20.49,4.06,Male,Yes,Sat,Dinner,2 109 | 25.21,4.29,Male,Yes,Sat,Dinner,2 110 | 18.24,3.76,Male,No,Sat,Dinner,2 111 | 14.31,4.0,Female,Yes,Sat,Dinner,2 112 | 14.0,3.0,Male,No,Sat,Dinner,2 113 | 7.25,1.0,Female,No,Sat,Dinner,1 114 | 38.07,4.0,Male,No,Sun,Dinner,3 115 | 23.95,2.55,Male,No,Sun,Dinner,2 116 | 25.71,4.0,Female,No,Sun,Dinner,3 117 | 17.31,3.5,Female,No,Sun,Dinner,2 118 | 29.93,5.07,Male,No,Sun,Dinner,4 119 | 10.65,1.5,Female,No,Thur,Lunch,2 120 | 12.43,1.8,Female,No,Thur,Lunch,2 121 | 24.08,2.92,Female,No,Thur,Lunch,4 122 | 11.69,2.31,Male,No,Thur,Lunch,2 123 | 13.42,1.68,Female,No,Thur,Lunch,2 124 | 14.26,2.5,Male,No,Thur,Lunch,2 125 | 15.95,2.0,Male,No,Thur,Lunch,2 126 | 12.48,2.52,Female,No,Thur,Lunch,2 127 | 29.8,4.2,Female,No,Thur,Lunch,6 128 | 8.52,1.48,Male,No,Thur,Lunch,2 129 | 14.52,2.0,Female,No,Thur,Lunch,2 130 | 11.38,2.0,Female,No,Thur,Lunch,2 131 | 22.82,2.18,Male,No,Thur,Lunch,3 132 | 19.08,1.5,Male,No,Thur,Lunch,2 133 | 20.27,2.83,Female,No,Thur,Lunch,2 134 | 11.17,1.5,Female,No,Thur,Lunch,2 135 | 12.26,2.0,Female,No,Thur,Lunch,2 136 | 18.26,3.25,Female,No,Thur,Lunch,2 137 | 8.51,1.25,Female,No,Thur,Lunch,2 138 | 10.33,2.0,Female,No,Thur,Lunch,2 139 | 14.15,2.0,Female,No,Thur,Lunch,2 140 | 16.0,2.0,Male,Yes,Thur,Lunch,2 141 | 13.16,2.75,Female,No,Thur,Lunch,2 142 | 17.47,3.5,Female,No,Thur,Lunch,2 143 | 34.3,6.7,Male,No,Thur,Lunch,6 144 | 41.19,5.0,Male,No,Thur,Lunch,5 145 | 27.05,5.0,Female,No,Thur,Lunch,6 146 | 16.43,2.3,Female,No,Thur,Lunch,2 147 | 8.35,1.5,Female,No,Thur,Lunch,2 148 | 18.64,1.36,Female,No,Thur,Lunch,3 149 | 11.87,1.63,Female,No,Thur,Lunch,2 150 | 9.78,1.73,Male,No,Thur,Lunch,2 151 | 7.51,2.0,Male,No,Thur,Lunch,2 152 | 14.07,2.5,Male,No,Sun,Dinner,2 153 | 13.13,2.0,Male,No,Sun,Dinner,2 154 | 17.26,2.74,Male,No,Sun,Dinner,3 155 | 24.55,2.0,Male,No,Sun,Dinner,4 156 | 19.77,2.0,Male,No,Sun,Dinner,4 157 | 29.85,5.14,Female,No,Sun,Dinner,5 158 | 48.17,5.0,Male,No,Sun,Dinner,6 159 | 25.0,3.75,Female,No,Sun,Dinner,4 160 | 13.39,2.61,Female,No,Sun,Dinner,2 161 | 16.49,2.0,Male,No,Sun,Dinner,4 162 | 21.5,3.5,Male,No,Sun,Dinner,4 163 | 12.66,2.5,Male,No,Sun,Dinner,2 164 | 16.21,2.0,Female,No,Sun,Dinner,3 165 | 13.81,2.0,Male,No,Sun,Dinner,2 166 | 17.51,3.0,Female,Yes,Sun,Dinner,2 167 | 24.52,3.48,Male,No,Sun,Dinner,3 168 | 20.76,2.24,Male,No,Sun,Dinner,2 169 | 31.71,4.5,Male,No,Sun,Dinner,4 170 | 10.59,1.61,Female,Yes,Sat,Dinner,2 171 | 10.63,2.0,Female,Yes,Sat,Dinner,2 172 | 50.81,10.0,Male,Yes,Sat,Dinner,3 173 | 15.81,3.16,Male,Yes,Sat,Dinner,2 174 | 7.25,5.15,Male,Yes,Sun,Dinner,2 175 | 31.85,3.18,Male,Yes,Sun,Dinner,2 176 | 16.82,4.0,Male,Yes,Sun,Dinner,2 177 | 32.9,3.11,Male,Yes,Sun,Dinner,2 178 | 17.89,2.0,Male,Yes,Sun,Dinner,2 179 | 14.48,2.0,Male,Yes,Sun,Dinner,2 180 | 9.6,4.0,Female,Yes,Sun,Dinner,2 181 | 34.63,3.55,Male,Yes,Sun,Dinner,2 182 | 34.65,3.68,Male,Yes,Sun,Dinner,4 183 | 23.33,5.65,Male,Yes,Sun,Dinner,2 184 | 45.35,3.5,Male,Yes,Sun,Dinner,3 185 | 23.17,6.5,Male,Yes,Sun,Dinner,4 186 | 40.55,3.0,Male,Yes,Sun,Dinner,2 187 | 20.69,5.0,Male,No,Sun,Dinner,5 188 | 20.9,3.5,Female,Yes,Sun,Dinner,3 189 | 30.46,2.0,Male,Yes,Sun,Dinner,5 190 | 18.15,3.5,Female,Yes,Sun,Dinner,3 191 | 23.1,4.0,Male,Yes,Sun,Dinner,3 192 | 15.69,1.5,Male,Yes,Sun,Dinner,2 193 | 19.81,4.19,Female,Yes,Thur,Lunch,2 194 | 28.44,2.56,Male,Yes,Thur,Lunch,2 195 | 15.48,2.02,Male,Yes,Thur,Lunch,2 196 | 16.58,4.0,Male,Yes,Thur,Lunch,2 197 | 7.56,1.44,Male,No,Thur,Lunch,2 198 | 10.34,2.0,Male,Yes,Thur,Lunch,2 199 | 43.11,5.0,Female,Yes,Thur,Lunch,4 200 | 13.0,2.0,Female,Yes,Thur,Lunch,2 201 | 13.51,2.0,Male,Yes,Thur,Lunch,2 202 | 18.71,4.0,Male,Yes,Thur,Lunch,3 203 | 12.74,2.01,Female,Yes,Thur,Lunch,2 204 | 13.0,2.0,Female,Yes,Thur,Lunch,2 205 | 16.4,2.5,Female,Yes,Thur,Lunch,2 206 | 20.53,4.0,Male,Yes,Thur,Lunch,4 207 | 16.47,3.23,Female,Yes,Thur,Lunch,3 208 | 26.59,3.41,Male,Yes,Sat,Dinner,3 209 | 38.73,3.0,Male,Yes,Sat,Dinner,4 210 | 24.27,2.03,Male,Yes,Sat,Dinner,2 211 | 12.76,2.23,Female,Yes,Sat,Dinner,2 212 | 30.06,2.0,Male,Yes,Sat,Dinner,3 213 | 25.89,5.16,Male,Yes,Sat,Dinner,4 214 | 48.33,9.0,Male,No,Sat,Dinner,4 215 | 13.27,2.5,Female,Yes,Sat,Dinner,2 216 | 28.17,6.5,Female,Yes,Sat,Dinner,3 217 | 12.9,1.1,Female,Yes,Sat,Dinner,2 218 | 28.15,3.0,Male,Yes,Sat,Dinner,5 219 | 11.59,1.5,Male,Yes,Sat,Dinner,2 220 | 7.74,1.44,Male,Yes,Sat,Dinner,2 221 | 30.14,3.09,Female,Yes,Sat,Dinner,4 222 | 12.16,2.2,Male,Yes,Fri,Lunch,2 223 | 13.42,3.48,Female,Yes,Fri,Lunch,2 224 | 8.58,1.92,Male,Yes,Fri,Lunch,1 225 | 15.98,3.0,Female,No,Fri,Lunch,3 226 | 13.42,1.58,Male,Yes,Fri,Lunch,2 227 | 16.27,2.5,Female,Yes,Fri,Lunch,2 228 | 10.09,2.0,Female,Yes,Fri,Lunch,2 229 | 20.45,3.0,Male,No,Sat,Dinner,4 230 | 13.28,2.72,Male,No,Sat,Dinner,2 231 | 22.12,2.88,Female,Yes,Sat,Dinner,2 232 | 24.01,2.0,Male,Yes,Sat,Dinner,4 233 | 15.69,3.0,Male,Yes,Sat,Dinner,3 234 | 11.61,3.39,Male,No,Sat,Dinner,2 235 | 10.77,1.47,Male,No,Sat,Dinner,2 236 | 15.53,3.0,Male,Yes,Sat,Dinner,2 237 | 10.07,1.25,Male,No,Sat,Dinner,2 238 | 12.6,1.0,Male,Yes,Sat,Dinner,2 239 | 32.83,1.17,Male,Yes,Sat,Dinner,2 240 | 35.83,4.67,Female,No,Sat,Dinner,3 241 | 29.03,5.92,Male,No,Sat,Dinner,3 242 | 27.18,2.0,Female,Yes,Sat,Dinner,2 243 | 22.67,2.0,Male,Yes,Sat,Dinner,2 244 | 17.82,1.75,Male,No,Sat,Dinner,2 245 | 18.78,3.0,Female,No,Thur,Dinner,2 246 | -------------------------------------------------------------------------------- /www/stars.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /www/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bslib-sidebar-main-bg: #f8f8f8; 3 | } 4 | 5 | .popover { 6 | --bs-popover-header-bg: #222; 7 | --bs-popover-header-color: #fff; 8 | } 9 | 10 | .popover .btn-close { 11 | filter: var(--bs-btn-close-white-filter); 12 | } 13 | shiny-chat-message table td, 14 | shiny-chat-message table th { 15 | border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color); 16 | padding: 3px; 17 | } 18 | 19 | shiny-chat-message table td { 20 | font-family: var(--bs-font-monospace); 21 | } 22 | 23 | #show_title:empty, #show_query:empty { 24 | /* Prevent empty title/query blocks from taking any space */ 25 | border: 0; 26 | padding: 0; 27 | margin-bottom: 0; 28 | } 29 | 30 | #show_title:empty, #show_query { 31 | /* We can't affect the flex parent's gap, so instead we use a negative margin 32 | to counteract it */ 33 | margin-top: calc(-1 * var(--bslib-mb-spacer)); 34 | } 35 | 36 | @media screen and (min-width: 575.98px) { 37 | @container bslib-value-box (max-width: 300px) { 38 | .bslib-value-box:not(.showcase-bottom) .value-box-grid .value-box-showcase { 39 | /* Hide showcase on all but the largest screen sizes to save room for plots */ 40 | display: none !important; 41 | } 42 | } 43 | } --------------------------------------------------------------------------------