├── .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 |
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 | }
--------------------------------------------------------------------------------