Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d1dd68a
feat(pkg-r): add QueryChatGreeter for schema-aware greetings
gadenbuie Jun 24, 2026
eba488b
fix(pkg-r): generic greeting when greeter$tables is cleared
gadenbuie Jun 24, 2026
16084de
fix(pkg-r): greeter table/prompt changes never invalidate greeting
gadenbuie Jun 24, 2026
ecbcdbc
feat(pkg-py): add QueryChatGreeter for schema-aware greetings
gadenbuie Jun 24, 2026
337aede
feat(pkg-py): generate greetings on a separate greeting client
gadenbuie Jun 24, 2026
f71377f
test(pkg-r): cover QueryChatGreeter behavior
gadenbuie Jun 24, 2026
61b09b8
test(pkg-py): cover QueryChatGreeter behavior
gadenbuie Jun 24, 2026
488c5a1
fix(pkg-r): validate include_in_greeting in add_tables
gadenbuie Jun 24, 2026
488844d
fix(pkg-py): validate include_in_greeting in add_tables
gadenbuie Jun 24, 2026
6ef6506
fix(pkg-r): validate include_in_greeting in add_table
gadenbuie Jun 24, 2026
beb16d7
fix(pkg-py): validate include_in_greeting in add_table
gadenbuie Jun 24, 2026
dbb0243
fix(pkg-r): scope greeting data dicts to included tables
gadenbuie Jun 24, 2026
978e58a
fix(pkg-py): scope greeting data dicts to included tables
gadenbuie Jun 24, 2026
d558765
fix(pkg-r): tidy greeter table edge cases
gadenbuie Jun 24, 2026
be1b15f
fix(pkg-py): tidy greeter table edge cases
gadenbuie Jun 24, 2026
1a89807
fix(pkg-r): validate add_tables greeting arg before mutating
gadenbuie Jun 24, 2026
9cb5dc5
fix(pkg-py): validate add_tables greeting arg and thread greeting cli…
gadenbuie Jun 24, 2026
da4820e
fix(pkg-r): keep global dict description in greeting, drop relationsh…
gadenbuie Jun 24, 2026
253e35b
fix(pkg-py): restore legacy greeting-turn filter and refine greeting …
gadenbuie Jun 24, 2026
ad0d695
fix(pkg-r): include deferred server table in greeting and render glob…
gadenbuie Jun 24, 2026
51ad7b1
fix(pkg-py): render global dict descriptions in table-less greetings
gadenbuie Jun 24, 2026
09c0b0f
fix(pkg-py): reject bare-string greeter table specs
gadenbuie Jun 24, 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
5 changes: 4 additions & 1 deletion pkg-py/src/querychat/_dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ def ui(
client_factory=self._client_factory,
greeting=self.greeting,
query_executor=self._require_query_executor("ui"),
greeting_client_factory=self._build_greeting_client,
)

return html.Div(
Expand Down Expand Up @@ -569,7 +570,9 @@ async def handle_chat(

if not state.initialize_greeting_if_preset():
greeting = ""
async for chunk in stream_response_async(state.client, GREETING_PROMPT):
async for chunk in stream_response_async(
state.build_greeting_client(), GREETING_PROMPT
):
greeting += chunk
state.set_greeting(greeting)

Expand Down
4 changes: 3 additions & 1 deletion pkg-py/src/querychat/_gradio.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ def initialize_greeting(state_dict: AppStateDict):

if not state.initialize_greeting_if_preset():
greeting = ""
for chunk in stream_response(state.client, GREETING_PROMPT):
for chunk in stream_response(
state.build_greeting_client(), GREETING_PROMPT
):
greeting += chunk
state.set_greeting(greeting)

Expand Down
95 changes: 89 additions & 6 deletions pkg-py/src/querychat/_querychat_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@
validate_source_group_compatibility,
)
from ._querychat_core import (
GREETING_PROMPT,
AppState,
AppStateDict,
create_app_state,
warn_multi_table_flat_accessor,
)
from ._querychat_greeter import QueryChatGreeter
from ._system_prompt import QueryChatSystemPrompt
from ._utils import MISSING, MISSING_TYPE, is_ibis_backend, is_ibis_table
from ._viz_utils import has_viz_deps, has_viz_tool
Expand Down Expand Up @@ -114,6 +114,7 @@ def __init__(
self._client_console = None

self._system_prompt: QueryChatSystemPrompt | None = None
self._greeter: QueryChatGreeter | None = None

if data_source is not None:
if table_name is None:
Expand All @@ -123,7 +124,7 @@ def __init__(
raise ValueError(
"table_name is required when data_source is provided"
)
self.add_table(data_source, table_name)
self.add_table(data_source, table_name, include_in_greeting=True)

def _build_system_prompt(
self,
Expand Down Expand Up @@ -319,10 +320,50 @@ def client(
def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str:
"""Generate a welcome greeting for the chat."""
self._require_initialized("generate_greeting")
chat = create_client(self._client_spec)
if self._system_prompt is not None:
chat.system_prompt = self._system_prompt.render(self.tools)
return str(chat.chat(GREETING_PROMPT, echo=echo))
return self.greeter.generate(echo=echo)

@property
def greeter(self) -> QueryChatGreeter:
"""Greeting configuration and generator for this QueryChat instance."""
if self._greeter is None:
self._greeter = QueryChatGreeter(self)
return self._greeter

def _build_greeting_client(
self, client_spec: str | chatlas.Chat | None = None
) -> chatlas.Chat:
"""
Build a fresh chat client configured with the greeting system prompt.

``client_spec`` overrides the instance client spec so the greeting is
generated with the same provider/model as the session client (for
example, when ``server(client=...)`` overrides it).
"""
tbls = [n for n in self.greeter.tables if n in self._data_sources]
sources = {n: self._data_sources[n] for n in tbls}
# Keep a dict if it describes an included table, or if it is a global
# (table-less) dict carrying a dict-level description. Drop the
# cross-table global fields (relationships, glossary) so a curated
# greeting subset can't leak excluded-table prose; per-table entries are
# scoped to the included tables at render time.
greeting_dicts = [
dd.model_copy(update={"relationships": [], "glossary": {}})
for dd in self._data_dicts
if any(n in tbls for n in dd.tables)
or (not dd.tables and dd.description)
]
greeting_prompt_obj = QueryChatSystemPrompt(
prompt_template=self.greeter.prompt,
data_sources=sources,
data_description=self._data_description,
extra_instructions=None,
categorical_threshold=self._categorical_threshold,
data_dicts=greeting_dicts,
)
chat = create_client(client_spec or self._client_spec)
chat.set_turns([])
chat.system_prompt = greeting_prompt_obj.render(None)
return chat

def console(
self,
Expand Down Expand Up @@ -381,6 +422,7 @@ def add_table(
table_name: str,
*,
replace: bool = False,
include_in_greeting: bool = False,
) -> None:
"""
Add or replace a table in the QueryChat instance.
Expand All @@ -394,9 +436,13 @@ def add_table(
replace
If True, replace an existing table with the same name.
If False (default), raise ValueError if the table already exists.
include_in_greeting
If True, include this table's schema in the greeting system prompt.

Raises
------
TypeError
If include_in_greeting is not a bool.
ValueError
If table_name already exists (and replace=False) or is invalid.
RuntimeError
Expand All @@ -409,6 +455,12 @@ def add_table(
"Add all tables before calling .server() or .app()."
)

if not isinstance(include_in_greeting, bool):
raise TypeError(
"include_in_greeting must be True or False, got "
f"{type(include_in_greeting).__name__}."
)

if not is_pins_board(data_source) and not re.match(
r"^[a-zA-Z][a-zA-Z0-9_]*$", table_name
):
Expand Down Expand Up @@ -445,12 +497,16 @@ def add_table(
self._query_executor.cleanup()
self._query_executor = None

if include_in_greeting and table_name not in self.greeter.tables:
self.greeter.tables = [*self.greeter.tables, table_name]

def add_tables( # noqa: PLR0912
self,
data_source: sqlalchemy.Engine | SQLBackend,
tables: list[str] | None = None,
*,
replace: bool = False,
include_in_greeting: bool | list[str] = False,
) -> None:
"""
Add multiple tables from a SQLAlchemy engine or Ibis backend in a single call.
Expand All @@ -471,6 +527,10 @@ def add_tables( # noqa: PLR0912
If ``True``, replace any existing table whose name appears in
``tables``. If ``False`` (default), raise ``ValueError`` if any
name already exists.
include_in_greeting
``True`` to include all added tables in the greeting, ``False`` (default)
for none, or a list of table names to include. Any other type raises
``TypeError``.

Raises
------
Expand Down Expand Up @@ -537,6 +597,18 @@ def normalized_builder(name: str) -> DataSource:
if table_name in self._data_sources and not replace:
raise ValueError(f"Table '{table_name}' already exists")

if isinstance(include_in_greeting, bool):
greeting_names = list(tables) if include_in_greeting else []
elif isinstance(include_in_greeting, list) and all(
isinstance(name, str) for name in include_in_greeting
):
greeting_names = [name for name in include_in_greeting if name in tables]
else:
raise TypeError(
"include_in_greeting must be True, False, or a list of table "
f"names, got {type(include_in_greeting).__name__}."
)

normalized = {name: normalized_builder(name) for name in tables}

staged: dict[str, DataSource] = {}
Expand All @@ -559,6 +631,12 @@ def normalized_builder(name: str) -> DataSource:
self._query_executor.cleanup()
self._query_executor = None

new_greeting = list(self.greeter.tables)
for name in greeting_names:
if name not in new_greeting:
new_greeting.append(name)
self.greeter.tables = new_greeting

def remove_table(self, table_name: str) -> None:
"""
Remove a table from the QueryChat instance.
Expand Down Expand Up @@ -597,6 +675,10 @@ def remove_table(self, table_name: str) -> None:

self._build_system_prompt(data_sources=next_data_sources)
self._data_sources = next_data_sources
if self._greeter is not None:
self._greeter.tables = [
n for n in self._greeter.tables if n != table_name
]
if self._query_executor is not None:
with contextlib.suppress(Exception):
self._query_executor.cleanup()
Expand Down Expand Up @@ -888,6 +970,7 @@ def _deserialize_state(self, state_data: AppStateDict | None) -> AppState:
client_factory=self._client_factory,
greeting=self.greeting,
query_executor=self._require_query_executor("_deserialize_state"),
greeting_client_factory=self._build_greeting_client,
)
if state_data:
state.update_from_dict(state_data)
Expand Down
19 changes: 16 additions & 3 deletions pkg-py/src/querychat/_querychat_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ def format_tool_result(result: ContentToolResult) -> str:
return ""




def format_query_error(e: Exception) -> str:
"""Format a query error with helpful guidance."""
error_msg = str(e).lower()
Expand Down Expand Up @@ -145,6 +143,7 @@ class AppState:
client: Chat
query_executor: QueryExecutor | None = None
greeting: Optional[str] = None
greeting_client_factory: Optional[Callable[[], Chat]] = None

active_table: str | None = None
# sql, title, error are per-table properties backed by _table_states
Expand Down Expand Up @@ -245,7 +244,10 @@ def get_display_messages(self) -> list[DisplayMessage]:

if text_parts:
text = "\n\n".join(text_parts)
# Skip the greeting prompt - it's an internal message
# Hide the synthetic greeting prompt that older releases injected
# as a user turn onto the shared client. New sessions generate
# greetings on a separate client and never create this turn, but
# state serialized by such releases still restores it verbatim.
if turn.role == "user" and text == GREETING_PROMPT:
continue
messages.append({"role": turn.role, "content": text})
Expand All @@ -271,6 +273,15 @@ def initialize_greeting_if_preset(self) -> bool:
return True
return False

def build_greeting_client(self) -> Chat:
"""Build a fresh chat client configured with the greeting system prompt."""
if self.greeting_client_factory is None:
raise RuntimeError(
"greeting_client_factory is not set on this AppState. "
"Pass greeting_client_factory to create_app_state()."
)
return self.greeting_client_factory()

def to_dict(self) -> AppStateDict:
"""Serialize state to dict for framework state stores."""
return {
Expand Down Expand Up @@ -317,6 +328,7 @@ def create_app_state(
client_factory: ClientFactory,
greeting: Optional[str] = None,
query_executor: QueryExecutor | None = None,
greeting_client_factory: Optional[Callable[[], Chat]] = None,
) -> AppState:
"""Create AppState with callbacks connected via holder pattern."""
state_holder: dict[str, AppState | None] = {"state": None}
Expand All @@ -339,6 +351,7 @@ def reset_callback(_table: str) -> None:
client=client,
query_executor=query_executor,
greeting=greeting,
greeting_client_factory=greeting_client_factory,
)
state_holder["state"] = state
return state
Expand Down
57 changes: 57 additions & 0 deletions pkg-py/src/querychat/_querychat_greeter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Greeting generation for QueryChat instances."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Literal

from ._querychat_core import GREETING_PROMPT

if TYPE_CHECKING:
from ._querychat_base import QueryChatBase


class QueryChatGreeter:
"""Controls greeting generation for a QueryChat instance. Access via ``qc.greeter``."""

def __init__(self, parent: QueryChatBase) -> None:
self._parent = parent
self._tables: list[str] = []
self._prompt: str | Path = Path(__file__).parent / "prompts" / "greeting.md"

@property
def tables(self) -> list[str]:
"""Table names whose context to include in the greeting."""
return self._tables

@tables.setter
def tables(self, value: list[str]) -> None:
if isinstance(value, str):
raise TypeError(
"greeter.tables must be a list of table names, not a single "
f"string. Did you mean [{value!r}]?"
)
if not isinstance(value, list) or not all(
isinstance(name, str) for name in value
):
raise TypeError(
"greeter.tables must be a list of table names, got "
f"{type(value).__name__}."
)
self._tables = value

@property
def prompt(self) -> str | Path:
"""The greeting template (string or file path)."""
return self._prompt

@prompt.setter
def prompt(self, value: str | Path) -> None:
self._prompt = value

def generate(self, *, echo: Literal["none", "output"] = "none") -> str:
"""Generate a greeting using the greeting system prompt."""
chat = self._parent._build_greeting_client()
txt = str(chat.chat(GREETING_PROMPT, echo=echo))
self._parent.greeting = txt
return txt
Loading
Loading