Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
1d5d103
feat: add multi-table support
cpsievert May 14, 2026
6daa693
feat: add DataDict for per-column metadata
cpsievert Jun 17, 2026
a7f9184
feat: add per-table accessor API
cpsievert Jun 17, 2026
89361d8
feat: show schema-fetch status and address review feedback
cpsievert Jun 17, 2026
e3f74fe
fix: address Copilot review feedback on multi-table branch
cpsievert Jun 17, 2026
538cb36
feat: DataDict namespacing with multi-dict support and YAML prompt re…
cpsievert Jun 17, 2026
26d7568
fix(py): pass table in Apply Filter button click to Shiny
cpsievert Jun 17, 2026
14b4d32
feat(r): add multi-table support
cpsievert Jun 18, 2026
f03c0cb
fix(r): simplify DataDict to plain list and wire schema merging
cpsievert Jun 18, 2026
dd9bf36
fix: R/Python parity for source compat check and multi-table query pr…
cpsievert Jun 18, 2026
1af327b
fix(py): render units, constraints, and type override from DataDict i…
cpsievert Jun 18, 2026
af34732
fix(r): render get_schema tool result as inline HTML, not a card
cpsievert Jun 18, 2026
40a7558
refactor(r): restructure TableAccessor to accept state directly
cpsievert Jun 19, 2026
a9b38ad
feat(r): add table() to server return for per-session reactive access
cpsievert Jun 19, 2026
767c77a
test(r): strengthen module tests to assert against session$returned keys
cpsievert Jun 19, 2026
1b20117
feat(py): add table() to ServerValues for per-session reactive access
cpsievert Jun 19, 2026
bd30a42
Merge remote-tracking branch 'origin/main' into feat/py-multi-table
cpsievert Jun 19, 2026
ff40298
`air format` (GitHub Actions)
cpsievert Jun 19, 2026
518b854
fix(r): fix CI failures from removed $data_source setter and missing …
cpsievert Jun 19, 2026
6edb1e0
Add yaml dependency
cpsievert Jun 19, 2026
c2df6d1
`usethis::use_tidy_description()` (GitHub Actions)
cpsievert Jun 19, 2026
84131c7
Improve the included example
cpsievert Jun 19, 2026
da997f5
no fill on values boxes
cpsievert Jun 19, 2026
68d900a
refactor(py): tighten top-level namespace
cpsievert Jun 19, 2026
65c0546
refactor(py): remove duplicate duckdb lockdown helper
cpsievert Jun 19, 2026
d32bd98
refactor(py): fold Apply Filter button test into existing e2e suite
cpsievert Jun 19, 2026
a8330d2
refactor(py): remove dead duckdb_get_schema helper
cpsievert Jun 19, 2026
93ee6fb
feat: aggregate schema display with Bootstrap popovers
cpsievert Jun 19, 2026
a64bcd9
feat: add current_table() reactive and multi-table app() support
cpsievert Jun 19, 2026
74b3812
Merge branch 'worktree-current-table' into feat/py-multi-table
cpsievert Jun 19, 2026
e497a2f
`air format` (GitHub Actions)
cpsievert Jun 19, 2026
ffba271
`devtools::document()` (GitHub Actions)
cpsievert Jun 19, 2026
007184d
fix(py): don't register get_schema tool when tools=None
cpsievert Jun 19, 2026
8641d8c
fix(py): correct multi-table error message from .tables[] to .table()
cpsievert Jun 19, 2026
67645ec
refactor: remove table() from QueryChat config object
cpsievert Jun 19, 2026
1a377a3
refactor(py): remove TableAccessor.ui() — Python-only with no R equiv…
cpsievert Jun 19, 2026
738ea43
refactor(py): make ServerValues._tables private
cpsievert Jun 19, 2026
18c8363
`devtools::document()` (GitHub Actions)
cpsievert Jun 19, 2026
7b98103
fix(py): remove duplicate GetSchemaResult/tests referencing removed .…
cpsievert Jun 19, 2026
0ed2fb1
docs: update for multi-table support, data dictionaries, and broader …
cpsievert Jun 19, 2026
34351a9
refactor(r): mark TableAccessor and read_data_dict as @keywords internal
cpsievert Jun 19, 2026
3cd46ba
fix(r): shut down DuckDB instance on DataFrameSource cleanup
cpsievert Jun 19, 2026
28fa08a
`devtools::document()` (GitHub Actions)
cpsievert Jun 19, 2026
b4887b0
fix(prompts): align R/Python tool prompt conventions and parity
cpsievert Jun 19, 2026
a440f67
docs: address PR review comments on changelog/news entries
cpsievert Jun 19, 2026
edc9de8
feat(js): replace schema popover with click-triggered table panel
cpsievert Jun 19, 2026
fc985a6
fix(r): prevent greeting prompt from appearing as user message with b…
cpsievert Jun 19, 2026
d5a2870
no fill on values boxes
cpsievert Jun 19, 2026
7903ecd
`air format` (GitHub Actions)
cpsievert Jun 19, 2026
22b4ee5
avoid non-ASCII characters
cpsievert Jun 19, 2026
ee9a4f1
fix(tests): use aria-rowcount selector to avoid schema panel table am…
cpsievert Jun 19, 2026
09dabe0
feat(py): add table() and current_table() to QueryChatExpress
cpsievert Jun 19, 2026
a634be7
Need latest shinychat
cpsievert Jun 19, 2026
db01377
fix remote
cpsievert Jun 19, 2026
bbfe499
refactor: serve schema panel data as JSON instead of parsing text
cpsievert Jun 19, 2026
a46281d
`air format` (GitHub Actions)
cpsievert Jun 19, 2026
686e71c
`usethis::use_tidy_description()` (GitHub Actions)
cpsievert Jun 19, 2026
fcc4c71
`devtools::document()` (GitHub Actions)
cpsievert Jun 19, 2026
c9a7472
fix: suppress tool request display for schema tool
cpsievert Jun 19, 2026
c33ed26
`air format` (GitHub Actions)
cpsievert Jun 19, 2026
eb61d09
fix test mock
cpsievert Jun 19, 2026
8ee218a
fix: address PR review feedback
cpsievert Jun 19, 2026
9e4e7a6
`devtools::document()` (GitHub Actions)
cpsievert Jun 19, 2026
6ed3061
Merge origin/main into feat/py-multi-table
cpsievert Jun 22, 2026
905a8db
refactor(pkg-py): replace StateDictAccessorMixin with StateDictQueryChat
cpsievert Jun 22, 2026
53a7788
refactor(pkg-py): soften multi-table flat accessor errors to FutureWa…
cpsievert Jun 22, 2026
f36db45
refactor(pkg-py): deduplicate missing-column validation in QueryExecutor
cpsievert Jun 22, 2026
99e011d
feat: add add_tables() for bulk table registration in R and Python
cpsievert Jun 22, 2026
47d8fae
fix(pkg-py): warn when system prompt rebuilds after chat history exists
cpsievert Jun 22, 2026
518e92e
Merge origin/main (shinychat 0.4.0 greeting API) into feat/py-multi-t…
cpsievert Jun 22, 2026
c750ec1
`air format` (GitHub Actions)
cpsievert Jun 22, 2026
84cc802
`devtools::document()` (GitHub Actions)
cpsievert Jun 22, 2026
ea9b977
fix(pkg-py): reduce return statement count to satisfy ruff PLR0911
cpsievert Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ animation.screenflow/
README_files/
README.html
.DS_Store
test-results/
python-package/examples/titanic.db
.quarto
*.db
Expand Down
8 changes: 8 additions & 0 deletions js/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ const jsTargets = [
source: "src/viz.ts",
output: "../pkg-r/inst/htmldep/viz.js",
},
{
source: "src/schema-display.js",
output: "../pkg-py/src/querychat/static/js/schema-display.js",
},
{
source: "src/schema-display.js",
output: "../pkg-r/inst/htmldep/schema-display.js",
},
];

const cssTargets = [
Expand Down
229 changes: 229 additions & 0 deletions js/src/schema-display.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
let lastDisplay = null;
let lastDisplayTime = 0;
const BATCH_MS = 1000;
let activePanel = null;

// -- Schema text parser --------------------------------------------------

function parseColumnsJson(json) {
return JSON.parse(json).map((col) => ({
name: col.name,
type: col.sql_type,
units: col.units || null,
description: col.description || null,
constraints: col.constraints && col.constraints.length > 0 ? col.constraints.join(', ') : null,
range:
col.min_val != null && col.max_val != null ? `${col.min_val} to ${col.max_val}` : null,
categories:
col.categories && col.categories.length > 0
? col.categories.map((v) => `'${v}'`).join(', ')
: null,
}));
}

// -- Table rendering -----------------------------------------------------

function esc(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

const TH =
'padding:0.35em 0.75em;text-align:left;white-space:nowrap;font-weight:600;' +
'border-bottom:2px solid var(--bs-border-color,#dee2e6);' +
'background:var(--bs-tertiary-bg,#f8f9fa);' +
'position:sticky;top:0;z-index:1;';
const TD_MONO =
'padding:0.3em 0.75em;white-space:nowrap;' +
'font-family:var(--bs-font-monospace,monospace);font-size:0.875em;' +
'border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));';
const TD_WRAP =
'padding:0.3em 0.75em;max-width:22em;overflow-wrap:break-word;' +
'border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));';
const TD_NOWRAP =
'padding:0.3em 0.75em;white-space:nowrap;' +
'border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.08));';

function renderTable(columns) {
const rows = columns
.map((col) => {
let typeCell = esc(col.type);
if (col.units) {
typeCell += ` <span style="color:var(--bs-secondary-color,#6c757d)">[${esc(col.units)}]</span>`;
}
const details = col.range
? esc(col.range)
: col.categories
? esc(col.categories)
: '';

return (
`<tr>` +
`<td style="${TD_MONO}">${esc(col.name)}</td>` +
`<td style="${TD_MONO}">${typeCell}</td>` +
`<td style="${TD_WRAP}">${col.description ? esc(col.description) : ''}</td>` +
`<td style="${TD_NOWRAP}">${col.constraints ? esc(col.constraints) : ''}</td>` +
`<td style="${TD_WRAP}">${details}</td>` +
`</tr>`
);
})
.join('');

return (
`<table style="border-collapse:collapse;min-width:100%;width:max-content;font-size:0.875em;">` +
`<thead><tr>` +
`<th style="${TH}">Column</th>` +
`<th style="${TH}">Type</th>` +
`<th style="${TH}">Description</th>` +
`<th style="${TH}">Constraints</th>` +
`<th style="${TH}">Range / Values</th>` +
`</tr></thead>` +
`<tbody>${rows}</tbody>` +
`</table>`
);
}

// -- Panel positioning & lifecycle ---------------------------------------

const PANEL_STYLE =
'position:fixed;z-index:9999;' +
'background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529);' +
'border:1px solid var(--bs-border-color,#dee2e6);' +
'border-radius:var(--bs-border-radius,0.375rem);' +
'box-shadow:0 4px 16px rgba(0,0,0,.15);' +
'overflow:auto;' +
'max-height:min(420px,60vh);';

function positionPanel(btn, panel) {
const rect = btn.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;

const pw = Math.min(Math.max(360, vw * 0.55), vw - 16);
panel.style.width = `${pw}px`;
panel.style.left = `${Math.max(8, Math.min(rect.left, vw - pw - 8))}px`;

// Prefer below; fall back to above if there's more room there
const spaceBelow = vh - rect.bottom - 8;
const spaceAbove = rect.top - 8;
if (spaceBelow >= 120 || spaceBelow >= spaceAbove) {
panel.style.top = `${rect.bottom + 4}px`;
} else {
const panelH = Math.min(420, spaceAbove);
panel.style.top = `${Math.max(8, rect.top - panelH - 4)}px`;
}
}

function closePanel() {
if (activePanel) {
activePanel.panel.hidden = true;
activePanel.btn.setAttribute('aria-expanded', 'false');
activePanel = null;
}
}

document.addEventListener('click', closePanel);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closePanel();
});
window.addEventListener(
'scroll',
(e) => {
if (activePanel && !activePanel.panel.contains(/** @type {Node} */ (e.target))) {
closePanel();
}
},
true,
);
window.addEventListener('resize', closePanel);

// -- Button + panel construction -----------------------------------------

function createBtn(tableName, columnsJson) {
const columns = parseColumnsJson(columnsJson);

const btn = document.createElement('button');
btn.type = 'button';
btn.style.cssText =
'background:none;border:none;padding:0;color:inherit;' +
'text-decoration:underline dotted;cursor:pointer;font-size:inherit;border-radius:2px;';
btn.textContent = tableName;
btn.setAttribute('aria-label', `Show schema for ${tableName}`);
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-haspopup', 'dialog');

const panel = document.createElement('div');
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', `${tableName} schema`);
panel.style.cssText = PANEL_STYLE;
panel.hidden = true;
panel.innerHTML = renderTable(columns);
document.body.appendChild(panel);

btn.addEventListener('click', (e) => {
e.stopPropagation();
if (activePanel && activePanel.panel === panel) {
closePanel();
return;
}
closePanel();
positionPanel(btn, panel);
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
activePanel = { btn, panel };
});

panel.addEventListener('click', (e) => e.stopPropagation());

return btn;
}

// -- Focus ring for keyboard users (Bootstrap resets button outline) -----

const style = document.createElement('style');
style.textContent =
'.qc-schema-display button:focus-visible{' +
'outline:2px solid currentColor;outline-offset:2px;border-radius:2px}';
document.head.appendChild(style);

// -- MutationObserver ---------------------------------------------------

function processCollector(sentinel) {
const now = Date.now();
const tableName = sentinel.dataset.table;
const btn = createBtn(tableName, sentinel.dataset.schemaJson);

if (lastDisplay && document.contains(lastDisplay) && now - lastDisplayTime < BATCH_MS) {
lastDisplay.appendChild(document.createTextNode(', '));
lastDisplay.appendChild(btn);
sentinel.remove();
} else {
const p = document.createElement('p');
p.className = 'qc-schema-display';
p.style.cssText =
'color:var(--bs-secondary-color,#6c757d);font-size:0.875em;margin:0.1rem 0;';
p.appendChild(document.createTextNode('🔍 Fetched schemas: '));
p.appendChild(btn);
sentinel.replaceWith(p);
lastDisplay = p;
}
lastDisplayTime = now;
}

new MutationObserver((mutations) => {
for (const { addedNodes } of mutations) {
for (const node of addedNodes) {
if (node.nodeType !== 1) continue;
if (/** @type {Element} */ (node).classList.contains('qc-schema-collector')) {
processCollector(/** @type {HTMLElement} */ (node));
} else {
/** @type {Element} */ (node)
.querySelectorAll('.qc-schema-collector')
.forEach((el) => processCollector(/** @type {HTMLElement} */ (el)));
}
}
}
}).observe(document.body, { subtree: true, childList: true });
25 changes: 25 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### New features

* `QueryChat()` now supports **multiple related tables**. Register additional tables with `add_table()` and the LLM can reason across all of them — joins, cross-table filters, aggregations. Per-table reactive state (`df()`, `sql()`, `title()`) is accessible via `qc_vals.table("name")` on the value returned by `server()`. (#195)

```python
qc = QueryChat(orders_df, "orders")
qc.add_table(customers_df, "customers")

qc_vals = qc.server()
qc_vals.table("orders").df()
qc_vals.table("customers").sql()
```

* A new **`DataDict`** type — integrating with the [data-dict](https://data-dict.tidyverse.org/) spec — lets you annotate tables and columns with plain-English descriptions loaded from a YAML file. This is the preferred way to provide additional context for the data, especially when multiple tables are relevant. The LLM receives these descriptions when it fetches the schema, helping it interpret ambiguous or domain-specific column names without any extra prompting. (#195)

```python
QueryChat(data_dict="data_dict.yaml")
```

* Added `PinSource`, a data source for chatting with datasets pinned to a [pins](https://pins.rstudio.com/) board. Works with parquet, CSV, JSON, and Arrow pins, and uses the pin's title, description, and tags as the default data description. Install the optional dependency with `pip install querychat[pins]`. (#246)

* File attachments are now enabled by default in the Shiny chat UI. Users can attach images, PDFs, and text files to their messages and the LLM will receive them. Disable with `allow_attachments=False` in `mod_ui()` or `QueryChat.ui()`. (#253)

### Breaking Changes

* The `data_source` property has been removed. Use `qc.table("name").data_source` to read a table's data source, and `qc.add_table(df, "name", replace=True)` to replace it. The `data_source` parameter to `server()` (Shiny) has also been removed; call `add_table()` before `server()` instead. (#195)

### Improvements

* Chat greetings now use shinychat's greeting API (requires shinychat >= 0.4.0). A provided `greeting` renders instantly when the app loads, and when no `greeting` is given one is generated on demand without being added to the conversation history. Generated greetings are now preserved across bookmark/restore. (#249)

* The system prompt is now lighter: full schema is no longer embedded upfront. Instead the LLM fetches per-table schema on demand via the new `querychat_get_schema` tool — and only when it needs to. When a `DataDict` is provided, the tool skips columns that already have descriptions, so the LLM only pays for what isn't already documented. (#195)
* The query tool result card now starts collapsed by default. Users can still expand it to see the SQL query and results. Set `QUERYCHAT_TOOL_DETAILS=expanded` to restore the previous behavior. (#239)

## [0.6.1] - 2026-05-26
Expand Down Expand Up @@ -86,6 +109,8 @@ Each framework's `QueryChat` provides `.app()` for quick standalone apps and `.u

### New features

* Added `PolarsLazySource` to support Polars LazyFrames as data sources. Data stays lazy until the render boundary, enabling efficient handling of large datasets. Pass a `polars.LazyFrame` directly to `QueryChat()` and queries will be executed lazily via Polars' SQLContext.

* `QueryChat.console()` was added to launch interactive console-based chat sessions with your data source, with persistent conversation state across invocations. (#168)

* `QueryChat.client()` can now create standalone querychat-enabled chat clients with configurable tools and callbacks, enabling use outside of Shiny applications. (#168)
Expand Down
13 changes: 3 additions & 10 deletions pkg-py/docs/build-intro.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,14 @@ title: Intro

While the `.app()` method is a great [quick start](index.qmd#quick-start) for exploring data, building custom apps with querychat unlocks the full power of integrating natural language data exploration with custom visualizations, layouts, and interactivity.

querychat is a particularly good fit for apps that have:

1. **A single data source** (or a set of related tables that can be joined)
2. **Multiple filters** that let users slice and explore the data in different ways
3. **Several visualizations and outputs** that all depend on the same filtered data

In these apps, querychat can replace or augment your filtering UI by allowing users to describe what they want to see in natural language. Instead of building complex filter controls, users can simply ask questions like "show me customers from California who spent over $1000 last quarter" and querychat will generate the appropriate SQL query.
querychat lets users ask questions of their data in plain language — filtering, sorting, summarizing, joining across tables, and creating visualizations — all without needing to write SQL or navigate complex filter UIs. You can use it as the primary exploration interface in a standalone app, or embed it alongside curated views in an existing dashboard to let users go deeper than the views you designed.

This is especially valuable when:

- Your data has many columns and building a UI for all possible filters would be overwhelming
- Users want to explore ad-hoc combinations of filters that you didn't anticipate
- You want to make data exploration more accessible to users who aren't comfortable with traditional filtering UIs

If you have an existing app with a data frame that flows through multiple outputs, querychat can be a natural addition to provide an alternative way to filter that data.
- You have multiple related tables that users may want to query and join
- You want to make data exploration more accessible to non-technical users

## General pattern

Expand Down
Loading
Loading