From 9283f402c799840dd528e43cc5b9e35644634f09 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:53:22 -0500 Subject: [PATCH 01/13] feat(py): parse label, time_zone, display, table constraints, aliases, conflicts from data dicts --- pkg-py/src/querychat/_data_dict.py | 25 +++++++++++++++ pkg-py/tests/test_data_dict.py | 49 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/pkg-py/src/querychat/_data_dict.py b/pkg-py/src/querychat/_data_dict.py index 35fee8b1..0d7a6fc0 100644 --- a/pkg-py/src/querychat/_data_dict.py +++ b/pkg-py/src/querychat/_data_dict.py @@ -76,6 +76,13 @@ class ColumnSpec(BaseModel): open range. Replaces live min/max statistics queries when set. examples Representative sample values shown to the LLM as context. + label + Display title for the column. + time_zone + Time zone for ``datetime`` columns, shown in the schema view. + display + When ``"restricted"``, the column is flagged as not for user-facing + output. """ @@ -88,6 +95,9 @@ class ColumnSpec(BaseModel): values: list[Any] | dict[str, str] | None = None range: list[Any] | None = None examples: list[Any] | None = None + label: str | None = None + time_zone: str | None = None + display: Literal["restricted"] | None = None @field_validator("range") @classmethod @@ -115,6 +125,11 @@ class TableSpec(BaseModel): columns Per-column specifications. Columns not listed here are documented using live statistics inferred from the data. + label + Display title for the table. + constraints + Table-level assertions conveyed to the LLM in the ``get_schema`` + response. """ @@ -122,6 +137,8 @@ class TableSpec(BaseModel): description: str | None = None details: str | None = None columns: list[ColumnSpec] = [] + label: str | None = None + constraints: list[str | ConstraintAssertion] = [] class RelationshipSpec(BaseModel): @@ -136,12 +153,20 @@ class RelationshipSpec(BaseModel): Cardinality string (e.g. ``"one-to-many"``). join SQL JOIN clause or expression that links the tables. + aliases + Alias-to-table map naming the role each join side plays (required + for self-joins). + conflicts + Column names that appear on both sides of the join with different + meanings. """ description: str | None = None cardinality: str | None = None join: str + aliases: dict[str, str] | None = None + conflicts: list[str] | None = None _LEGACY_TABLES_MESSAGE = """\ diff --git a/pkg-py/tests/test_data_dict.py b/pkg-py/tests/test_data_dict.py index a9399ea9..b316045e 100644 --- a/pkg-py/tests/test_data_dict.py +++ b/pkg-py/tests/test_data_dict.py @@ -318,3 +318,52 @@ def test_to_prompt_dict_includes_glossary() -> None: dd = DataDict(glossary={"ARR": "Annual Recurring Revenue"}) d = dd.to_prompt_dict() assert d["glossary"]["ARR"] == "Annual Recurring Revenue" + + +def test_new_field_parsing(tmp_path: Path) -> None: + yaml_content = """\ +tables: + - name: orders + label: Orders + constraints: + - assert: end_date >= start_date + description: Valid date range + - primary_key + columns: + - name: status + type: enum + label: Order status + values: {W: Web, R: Retail} + - name: ssn + type: string + display: restricted + - name: created_at + type: datetime + time_zone: America/Chicago +relationships: + - join: mother.otter_no = pup.pup_number + cardinality: one-to-many + aliases: {mother: otters, pup: otters} + conflicts: [description] +""" + f = tmp_path / "spec.yaml" + f.write_text(yaml_content) + dd = DataDict.from_yaml(f) + + t = dd.tables[0] + assert t.label == "Orders" + assert len(t.constraints) == 2 + table_constraint = t.constraints[0] + assert not isinstance(table_constraint, str) + assert table_constraint.assert_ == "end_date >= start_date" + assert table_constraint.description == "Valid date range" + assert t.constraints[1] == "primary_key" + + status, ssn, created_at = t.columns + assert status.label == "Order status" + assert ssn.display == "restricted" + assert created_at.time_zone == "America/Chicago" + + rel = dd.relationships[0] + assert rel.aliases == {"mother": "otters", "pup": "otters"} + assert rel.conflicts == ["description"] From 1a50337763c60dfcf2f82d5482a7fea7114e760d Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:53:41 -0500 Subject: [PATCH 02/13] feat(py): carry label, time_zone, display, examples, value_labels through ColumnMeta --- pkg-py/src/querychat/_data_dict.py | 19 ++++++++---- pkg-py/src/querychat/_datasource.py | 15 ++++++++++ pkg-py/tests/test_data_dict.py | 46 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/pkg-py/src/querychat/_data_dict.py b/pkg-py/src/querychat/_data_dict.py index 0d7a6fc0..697e120b 100644 --- a/pkg-py/src/querychat/_data_dict.py +++ b/pkg-py/src/querychat/_data_dict.py @@ -305,18 +305,25 @@ def get_table_schema( meta.min_val = spec.range[0] meta.max_val = spec.range[1] if spec.values is not None: - vals = ( - list(spec.values) - if isinstance(spec.values, dict) - else spec.values - ) - meta.categories = [str(v) for v in vals] + if isinstance(spec.values, dict): + meta.categories = [str(k) for k in spec.values] + meta.value_labels = dict(spec.values) + else: + meta.categories = [str(v) for v in spec.values] if spec.description is not None: meta.description = spec.description if spec.units is not None: meta.units = spec.units if spec.constraints: meta.constraints = [str(c) for c in spec.constraints] + if spec.label is not None: + meta.label = spec.label + if spec.time_zone is not None: + meta.time_zone = spec.time_zone + if spec.display is not None: + meta.display = spec.display + if spec.examples is not None and spec.display != "restricted": + meta.examples = [str(e) for e in spec.examples] else: undocumented.append(meta) diff --git a/pkg-py/src/querychat/_datasource.py b/pkg-py/src/querychat/_datasource.py index 5dee42c6..59f64c47 100644 --- a/pkg-py/src/querychat/_datasource.py +++ b/pkg-py/src/querychat/_datasource.py @@ -60,6 +60,21 @@ class ColumnMeta: constraints: list[str] = field(default_factory=list) """Free-text constraints (e.g. 'non-negative').""" + examples: list[str] = field(default_factory=list) + """Sample values from the data dictionary (withheld when restricted).""" + + value_labels: dict[str, str] | None = None + """Optional value -> label map from map-form `values` in the data dictionary.""" + + display: str | None = None + """Set to 'restricted' for columns not intended for user-facing output.""" + + label: str | None = None + """Display title from the data dictionary.""" + + time_zone: str | None = None + """Time zone for datetime columns, from the data dictionary.""" + def format_schema(table_name: str, columns: list[ColumnMeta]) -> str: """Format column metadata into schema string.""" diff --git a/pkg-py/tests/test_data_dict.py b/pkg-py/tests/test_data_dict.py index b316045e..e3ff3eae 100644 --- a/pkg-py/tests/test_data_dict.py +++ b/pkg-py/tests/test_data_dict.py @@ -367,3 +367,49 @@ def test_new_field_parsing(tmp_path: Path) -> None: rel = dd.relationships[0] assert rel.aliases == {"mother": "otters", "pup": "otters"} assert rel.conflicts == ["description"] + + +def test_get_table_schema_new_column_fields() -> None: + dd = DataDict( + tables=[ + TableSpec( + name="orders", + columns=[ + ColumnSpec( + name="status", + label="Order status", + values={"W": "Web", "R": "Retail"}, + examples=["W", "R"], + ), + ColumnSpec( + name="ssn", + display="restricted", + examples=["000-00-0000"], + ), + ColumnSpec(name="created_at", time_zone="America/Chicago"), + ], + ) + ], + ) + df = pl.DataFrame( + { + "status": ["W", "R"], + "ssn": ["x", "y"], + "created_at": [1, 2], + } + ) + executor = _make_executor(df, "orders") + cols = dd.get_table_schema("orders", executor, categorical_threshold=10) + col_map = {c.name: c for c in cols} + + status = col_map["status"] + assert status.label == "Order status" + assert status.categories == ["W", "R"] + assert status.value_labels == {"W": "Web", "R": "Retail"} + assert status.examples == ["W", "R"] + + ssn = col_map["ssn"] + assert ssn.display == "restricted" + assert ssn.examples == [] # withheld on restricted columns + + assert col_map["created_at"].time_zone == "America/Chicago" From fba125249e971c1db165756b60db48b5eaefbb5d Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:54:13 -0500 Subject: [PATCH 03/13] feat(py): render new dict fields in schema text; hide restricted columns from schema card --- pkg-py/src/querychat/_datasource.py | 17 ++++++++++++++ pkg-py/src/querychat/tools.py | 8 +++++++ pkg-py/tests/test_datasource.py | 35 +++++++++++++++++++++++++++++ pkg-py/tests/test_tools.py | 31 +++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/pkg-py/src/querychat/_datasource.py b/pkg-py/src/querychat/_datasource.py index 59f64c47..e5cacb9e 100644 --- a/pkg-py/src/querychat/_datasource.py +++ b/pkg-py/src/querychat/_datasource.py @@ -86,6 +86,12 @@ def format_schema(table_name: str, columns: list[ColumnMeta]) -> str: header += f" [{col.units}]" lines.append(header) + if col.display == "restricted": + lines.append(" [restricted: do not display in user-facing output]") + if col.label: + lines.append(f" Label: {col.label}") + if col.time_zone: + lines.append(f" Time zone: {col.time_zone}") if col.description: lines.append(f" Description: {col.description}") if col.constraints: @@ -99,6 +105,17 @@ def format_schema(table_name: str, columns: list[ColumnMeta]) -> str: elif col.categories: cats = ", ".join(f"'{v}'" for v in col.categories) lines.append(f" Categorical values: {cats}") + if col.value_labels: + labels = ", ".join(f"{k}={v}" for k, v in col.value_labels.items()) + lines.append(f" Labels: {labels}") + if col.examples: + shown = col.examples[:5] + more = ( + f" … ({len(col.examples) - 5} more)" + if len(col.examples) > 5 + else "" + ) + lines.append(f" Examples: {', '.join(shown)}{more}") return "\n".join(lines) diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 8cfc5d87..2f66c43f 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -68,6 +68,11 @@ def get_schema(table_name: str) -> ContentToolResult: columns = executor.get_column_details(table_name, categorical_threshold) schema_text = format_schema(table_name, columns) + if dd is not None: + tspec = dd.get_table(table_name) + if tspec is not None and tspec.constraints: + rendered = "\n".join(f"- {c}" for c in tspec.constraints) + schema_text += f"\n\nTable constraints:\n{rendered}" return GetSchemaResult( value=schema_text, table_name=table_name, @@ -127,6 +132,9 @@ def tool_get_schema( def _schema_table(columns: list[ColumnMeta]) -> Tag: + # Restricted columns are LLM-visible (annotated in the schema text) but + # hidden from user-facing output per the data-dict spec. + columns = [c for c in columns if c.display != "restricted"] headers = ("Column", "Type", "Range / Values", "Description", "Constraints") rows: list[Tag] = [] diff --git a/pkg-py/tests/test_datasource.py b/pkg-py/tests/test_datasource.py index 1f17389f..27adce97 100644 --- a/pkg-py/tests/test_datasource.py +++ b/pkg-py/tests/test_datasource.py @@ -584,3 +584,38 @@ def test_sqlalchemy_cleanup_does_not_dispose_caller_owned_engine( dispose.assert_not_called() assert len(source.get_data()) > 0 + + +def test_format_schema_new_annotations() -> None: + cols = [ + ColumnMeta( + name="status", + sql_type="STRING", + kind="text", + categories=["W", "R"], + value_labels={"W": "Web", "R": "Retail"}, + label="Order status", + examples=["W", "R", "W", "R", "W", "R", "W"], + ), + ColumnMeta( + name="ssn", + sql_type="STRING", + kind="text", + display="restricted", + ), + ColumnMeta( + name="created_at", + sql_type="TIMESTAMP", + kind="date", + time_zone="America/Chicago", + ), + ] + text = format_schema("orders", cols) + + assert " Label: Order status" in text + assert " Categorical values: 'W', 'R'" in text + assert " Labels: W=Web, R=Retail" in text + # examples capped at 5 + assert " Examples: W, R, W, R, W … (2 more)" in text + assert "[restricted: do not display in user-facing output]" in text + assert " Time zone: America/Chicago" in text diff --git a/pkg-py/tests/test_tools.py b/pkg-py/tests/test_tools.py index 66ff6848..8441e466 100644 --- a/pkg-py/tests/test_tools.py +++ b/pkg-py/tests/test_tools.py @@ -329,3 +329,34 @@ def test_get_schema_tool_uses_singular_column_preview() -> None: result = tool.func(table_name="orders") assert result.extra["display"].value_preview == "1 column" + + +def test_schema_card_hides_restricted_columns() -> None: + from querychat.tools import _schema_table + + cols = [ + ColumnMeta(name="id", sql_type="INTEGER", kind="numeric"), + ColumnMeta( + name="ssn", sql_type="STRING", kind="text", display="restricted" + ), + ] + html = str(_schema_table(cols)) + assert "id" in html + assert "ssn" not in html + + +def test_get_schema_includes_table_constraints() -> None: + df = pl.DataFrame({"amount": [1, 2]}) + executor, table_names = _make_executor_and_table(df, "orders") + dd = DataDict( + tables=[ + TableSpec( + name="orders", + constraints=[{"assert": "amount >= 0", "description": "Non-negative"}], + ) + ], + ) + tool = tool_get_schema([dd], executor, table_names, categorical_threshold=10) + result = tool.func(table_name="orders") + assert "Table constraints:" in result.value + assert "- amount >= 0 (Non-negative)" in result.value From c90a5abc168455a5b700751732f264be35f09311 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:55:37 -0500 Subject: [PATCH 04/13] feat(py): include table label and relationship extras in prompt dict block --- pkg-py/CHANGELOG.md | 2 + pkg-py/src/querychat/_data_dict.py | 67 ++++++++++++++++++----------- pkg-py/src/querychat/_datasource.py | 10 +++-- pkg-py/tests/test_data_dict.py | 20 +++++++++ 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 6cec56df..4bf67e0c 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* Data dictionaries now surface more of the data-dict spec to the LLM: table-level assertion `constraints`, column `label`/`time_zone`/`examples` (capped at 5), value-to-label maps (rendered as a `Labels:` annotation while keeping category values clean for SQL), and relationship `aliases`/`conflicts`. Columns marked `display: restricted` are annotated in the LLM's schema and hidden from the user-facing schema card. (#316 follow-up) + * **Faster iterative visualization, and support for layer-level data sources.** Visualization queries now execute as a whole through `ggsql.CachingReader`: the underlying SQL runs against your data source once and the result is memoized for the chat session, so re-rendering a chart with a different encoding no longer re-queries the source (a win for remote databases especially). Visualization layers can also draw from other relations in the query — e.g. raw points from the final `SELECT` with a summary line from a CTE — via `DRAW ... MAPPING ... FROM `. Caching can be tuned or disabled with the `GGSQL_CACHE_TTL`, `GGSQL_CACHE_MAX_BYTES`, and `GGSQL_CACHE_DISABLED` environment variables. (#321) ### Bug fixes diff --git a/pkg-py/src/querychat/_data_dict.py b/pkg-py/src/querychat/_data_dict.py index 697e120b..77a54114 100644 --- a/pkg-py/src/querychat/_data_dict.py +++ b/pkg-py/src/querychat/_data_dict.py @@ -268,7 +268,17 @@ def to_prompt_dict(self) -> dict[str, Any]: result["description"] = self.description if self.tables: result["tables"] = { - t.name: ({"description": t.description} if t.description else None) + t.name: ( + { + k: v + for k, v in { + "description": t.description, + "label": t.label, + }.items() + if v is not None + } + or None + ) for t in self.tables } if self.relationships: @@ -299,31 +309,7 @@ def get_table_schema( for meta in metas: spec = documented.get(meta.name) if spec is not None: - if spec.type is not None: - meta.sql_type = spec.type - if spec.range is not None: - meta.min_val = spec.range[0] - meta.max_val = spec.range[1] - if spec.values is not None: - if isinstance(spec.values, dict): - meta.categories = [str(k) for k in spec.values] - meta.value_labels = dict(spec.values) - else: - meta.categories = [str(v) for v in spec.values] - if spec.description is not None: - meta.description = spec.description - if spec.units is not None: - meta.units = spec.units - if spec.constraints: - meta.constraints = [str(c) for c in spec.constraints] - if spec.label is not None: - meta.label = spec.label - if spec.time_zone is not None: - meta.time_zone = spec.time_zone - if spec.display is not None: - meta.display = spec.display - if spec.examples is not None and spec.display != "restricted": - meta.examples = [str(e) for e in spec.examples] + _apply_column_spec(meta, spec) else: undocumented.append(meta) @@ -354,3 +340,32 @@ def from_yaml(cls, path: Path | str) -> DataDict: if dd.name is None: dd = dd.model_copy(update={"name": path.stem}) return dd + + +def _apply_column_spec(meta: ColumnMeta, spec: ColumnSpec) -> None: + """Overlay a :class:`ColumnSpec` onto a live :class:`ColumnMeta`.""" + if spec.type is not None: + meta.sql_type = spec.type + if spec.range is not None: + meta.min_val = spec.range[0] + meta.max_val = spec.range[1] + if spec.values is not None: + if isinstance(spec.values, dict): + meta.categories = [str(k) for k in spec.values] + meta.value_labels = dict(spec.values) + else: + meta.categories = [str(v) for v in spec.values] + if spec.description is not None: + meta.description = spec.description + if spec.units is not None: + meta.units = spec.units + if spec.constraints: + meta.constraints = [str(c) for c in spec.constraints] + if spec.label is not None: + meta.label = spec.label + if spec.time_zone is not None: + meta.time_zone = spec.time_zone + if spec.display is not None: + meta.display = spec.display + if spec.examples is not None and spec.display != "restricted": + meta.examples = [str(e) for e in spec.examples] diff --git a/pkg-py/src/querychat/_datasource.py b/pkg-py/src/querychat/_datasource.py index e5cacb9e..3e26046f 100644 --- a/pkg-py/src/querychat/_datasource.py +++ b/pkg-py/src/querychat/_datasource.py @@ -76,6 +76,10 @@ class ColumnMeta: """Time zone for datetime columns, from the data dictionary.""" +MAX_SCHEMA_EXAMPLES = 5 +"""Maximum number of data-dict example values shown per column in schema text.""" + + def format_schema(table_name: str, columns: list[ColumnMeta]) -> str: """Format column metadata into schema string.""" lines = [f"Table: {table_name}", "Columns:"] @@ -109,10 +113,10 @@ def format_schema(table_name: str, columns: list[ColumnMeta]) -> str: labels = ", ".join(f"{k}={v}" for k, v in col.value_labels.items()) lines.append(f" Labels: {labels}") if col.examples: - shown = col.examples[:5] + shown = col.examples[:MAX_SCHEMA_EXAMPLES] more = ( - f" … ({len(col.examples) - 5} more)" - if len(col.examples) > 5 + f" … ({len(col.examples) - MAX_SCHEMA_EXAMPLES} more)" + if len(col.examples) > MAX_SCHEMA_EXAMPLES else "" ) lines.append(f" Examples: {', '.join(shown)}{more}") diff --git a/pkg-py/tests/test_data_dict.py b/pkg-py/tests/test_data_dict.py index e3ff3eae..75a1857a 100644 --- a/pkg-py/tests/test_data_dict.py +++ b/pkg-py/tests/test_data_dict.py @@ -413,3 +413,23 @@ def test_get_table_schema_new_column_fields() -> None: assert ssn.examples == [] # withheld on restricted columns assert col_map["created_at"].time_zone == "America/Chicago" + + +def test_to_prompt_dict_new_pass_throughs() -> None: + from querychat._data_dict import RelationshipSpec + + dd = DataDict( + tables=[TableSpec(name="orders", description="Orders", label="Orders title")], + relationships=[ + RelationshipSpec( + join="mother.otter_no = pup.pup_number", + cardinality="one-to-many", + aliases={"mother": "otters", "pup": "otters"}, + conflicts=["description"], + ) + ], + ) + d = dd.to_prompt_dict() + assert d["tables"]["orders"]["label"] == "Orders title" + assert d["relationships"][0]["aliases"] == {"mother": "otters", "pup": "otters"} + assert d["relationships"][0]["conflicts"] == ["description"] From 5d505c63d76696ad7c982071eaa07e9ac70ac228 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:55:57 -0500 Subject: [PATCH 05/13] feat(r): pass table label and relationship aliases/conflicts through to prompt --- pkg-r/R/DataDict.R | 13 +++++++------ pkg-r/tests/testthat/test-DataDict.R | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/pkg-r/R/DataDict.R b/pkg-r/R/DataDict.R index b4ae7333..01138aa4 100644 --- a/pkg-r/R/DataDict.R +++ b/pkg-r/R/DataDict.R @@ -65,11 +65,10 @@ data_dict_to_prompt_list <- function(dd) { tables <- dd_tables_by_name(dd) if (length(tables) > 0) { result[["tables"]] <- lapply(tables, function(ts) { - if (is.null(ts[["description"]])) { - NULL - } else { - list(description = ts[["description"]]) - } + entry <- compact( + list(description = ts[["description"]], label = ts[["label"]]) + ) + if (length(entry) == 0) NULL else entry }) } if (length(dd[["relationships"]]) > 0) { @@ -78,7 +77,9 @@ data_dict_to_prompt_list <- function(dd) { list( join = rs[["join"]], description = rs[["description"]], - cardinality = rs[["cardinality"]] + cardinality = rs[["cardinality"]], + aliases = rs[["aliases"]], + conflicts = rs[["conflicts"]] ) ) }) diff --git a/pkg-r/tests/testthat/test-DataDict.R b/pkg-r/tests/testthat/test-DataDict.R index 858680e4..2d92a11c 100644 --- a/pkg-r/tests/testthat/test-DataDict.R +++ b/pkg-r/tests/testthat/test-DataDict.R @@ -170,6 +170,27 @@ describe("data_dict_to_prompt_list()", { expect_false("glossary" %in% names(result)) }) + it("passes through table label and relationship aliases/conflicts", { + dd <- list( + tables = list( + list(name = "orders", description = "Orders", label = "Orders title") + ), + relationships = list( + list( + join = "mother.otter_no = pup.pup_number", + cardinality = "one-to-many", + aliases = list(mother = "otters", pup = "otters"), + conflicts = list("description") + ) + ) + ) + result <- data_dict_to_prompt_list(dd) + expect_equal(result[["tables"]][["orders"]][["label"]], "Orders title") + rel <- result[["relationships"]][[1]] + expect_equal(rel[["aliases"]], list(mother = "otters", pup = "otters")) + expect_equal(rel[["conflicts"]], list("description")) + }) + it("includes table entry as NULL when table has no description", { dd <- list(tables = list(list(name = "no_desc"))) result <- data_dict_to_prompt_list(dd) From 3da91cbf90b74b8b21d100230cc13ad2d374c589 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:57:17 -0500 Subject: [PATCH 06/13] feat(r): render new dict fields in schema; hide restricted columns from schema card --- pkg-r/R/DBISource.R | 111 ++++++++++++++++---- pkg-r/R/QueryExecutor.R | 3 +- pkg-r/R/TblSqlSource.R | 3 +- pkg-r/R/querychat_tools.R | 6 ++ pkg-r/tests/testthat/test-querychat_tools.R | 61 +++++++++++ 5 files changed, 164 insertions(+), 20 deletions(-) diff --git a/pkg-r/R/DBISource.R b/pkg-r/R/DBISource.R index f4bc1320..e7cc97e8 100644 --- a/pkg-r/R/DBISource.R +++ b/pkg-r/R/DBISource.R @@ -131,7 +131,8 @@ DBISource <- R6::R6Class( list( text = format_schema_from_details( as.character(DBI::dbQuoteIdentifier(private$.conn, self$table_name)), - details + details, + table_spec = table_spec ), columns = details ) @@ -232,6 +233,26 @@ DBISource <- R6::R6Class( ) ) +# Render spec `constraints` entries (barewords and assertion objects) to text. +format_constraint_entries <- function(constraints) { + vapply( + constraints, + function(c) { + if (is.list(c)) { + # Assertion object: {assert, language?, description?} + txt <- c[["assert"]] %||% "" + if (!is.null(c[["description"]])) { + txt <- paste0(txt, " (", c[["description"]], ")") + } + txt + } else { + as.character(c) + } + }, + character(1) + ) +} + # Returns a list of named lists (one per column) with all schema metadata. # Used by both format_schema_from_details() (LLM text) and JSON serialization (UI). build_column_details_impl <- function( @@ -371,19 +392,9 @@ build_column_details_impl <- function( min_val <- NA_character_ max_val <- NA_character_ categories <- list() + value_labels <- NULL constraints <- if (length(spec[["constraints"]]) > 0) { - lapply(spec[["constraints"]], function(c) { - if (is.list(c)) { - # Assertion object: {assert, language?, description?} - txt <- c[["assert"]] %||% "" - if (!is.null(c[["description"]])) { - txt <- paste0(txt, " (", c[["description"]], ")") - } - txt - } else { - as.character(c) - } - }) + as.list(format_constraint_entries(spec[["constraints"]])) } else { list() } @@ -412,8 +423,10 @@ build_column_details_impl <- function( } else if (col %in% text_columns) { if (!is.null(spec[["values"]])) { vals <- spec[["values"]] - # Map form (value -> label): the values are the names + # Map form (value -> label): values are the names, labels the values if (!is.null(names(vals))) { + value_labels <- as.list(as.character(vals)) + names(value_labels) <- names(vals) vals <- as.list(names(vals)) } categories <- as.list(as.character(vals)) @@ -428,6 +441,18 @@ build_column_details_impl <- function( sql_type = sql_type, units = spec[["units"]] %||% NA_character_, description = spec[["description"]] %||% NA_character_, + label = spec[["label"]] %||% NA_character_, + time_zone = spec[["time_zone"]] %||% NA_character_, + display = spec[["display"]] %||% NA_character_, + examples = if ( + !is.null(spec[["examples"]]) && + !identical(spec[["display"]], "restricted") + ) { + as.list(as.character(spec[["examples"]])) + } else { + list() + }, + value_labels = value_labels, min_val = min_val, max_val = max_val, categories = categories, @@ -436,14 +461,32 @@ build_column_details_impl <- function( }) } -# Format a list of column details (from build_column_details_impl) into LLM schema text. -format_schema_from_details <- function(table_name_display, details) { +# Format a list of column details (from build_column_details_impl) into LLM +# schema text. When `table_spec` is given, table-level constraints are appended. +format_schema_from_details <- function( + table_name_display, + details, + table_spec = NULL +) { schema_lines <- c(paste("Table:", table_name_display), "Columns:") for (col in details) { line <- paste0("- ", col$name, " (", col$sql_type, ")") if (!is.na(col$units)) { line <- paste0(line, " [", col$units, "]") } + if (!is.na(col$display) && identical(col$display, "restricted")) { + line <- paste( + line, + " [restricted: do not display in user-facing output]", + sep = "\n" + ) + } + if (!is.na(col$label)) { + line <- paste(line, paste0(" Label: ", col$label), sep = "\n") + } + if (!is.na(col$time_zone)) { + line <- paste(line, paste0(" Time zone: ", col$time_zone), sep = "\n") + } if (!is.na(col$description)) { line <- paste( line, @@ -472,9 +515,40 @@ format_schema_from_details <- function(table_name_display, details) { sep = "\n" ) } + if (length(col$value_labels) > 0) { + labels_str <- paste0( + names(col$value_labels), + "=", + as.character(col$value_labels), + collapse = ", " + ) + line <- paste(line, paste0(" Labels: ", labels_str), sep = "\n") + } + if (length(col$examples) > 0) { + shown <- head(col$examples, 5) + more <- if (length(col$examples) > 5) { + paste0(" … (", length(col$examples) - 5, " more)") + } else { + "" + } + line <- paste( + line, + paste0(" Examples: ", paste(shown, collapse = ", "), more), + sep = "\n" + ) + } schema_lines <- c(schema_lines, line) } - paste(schema_lines, collapse = "\n") + text <- paste(schema_lines, collapse = "\n") + if (length(table_spec[["constraints"]]) > 0) { + rendered <- format_constraint_entries(table_spec[["constraints"]]) + text <- paste0( + text, + "\n\nTable constraints:\n", + paste0("- ", rendered, collapse = "\n") + ) + } + text } get_schema_impl <- function( @@ -496,7 +570,8 @@ get_schema_impl <- function( ) format_schema_from_details( as.character(DBI::dbQuoteIdentifier(conn, table_name)), - details + details, + table_spec = table_spec ) } diff --git a/pkg-r/R/QueryExecutor.R b/pkg-r/R/QueryExecutor.R index 375257ee..cac616a5 100644 --- a/pkg-r/R/QueryExecutor.R +++ b/pkg-r/R/QueryExecutor.R @@ -155,7 +155,8 @@ DuckDBExecutor <- R6::R6Class( list( text = format_schema_from_details( as.character(DBI::dbQuoteIdentifier(private$conn, table_name)), - details + details, + table_spec = table_spec ), columns = details ) diff --git a/pkg-r/R/TblSqlSource.R b/pkg-r/R/TblSqlSource.R index 63846bf5..94e741be 100644 --- a/pkg-r/R/TblSqlSource.R +++ b/pkg-r/R/TblSqlSource.R @@ -130,7 +130,8 @@ TblSqlSource <- R6::R6Class( list( text = format_schema_from_details( as.character(DBI::dbQuoteIdentifier(private$.conn, self$table_name)), - details + details, + table_spec = table_spec ), columns = details ) diff --git a/pkg-r/R/querychat_tools.R b/pkg-r/R/querychat_tools.R index b7e0b075..497b7c3e 100644 --- a/pkg-r/R/querychat_tools.R +++ b/pkg-r/R/querychat_tools.R @@ -358,6 +358,12 @@ querychat_tool_result <- function( } schema_table <- function(columns) { + # Restricted columns are LLM-visible (annotated in the schema text) but + # hidden from user-facing output per the data-dict spec. + columns <- Filter( + function(column) !identical(column$display, "restricted"), + columns + ) headers <- c( "Column", "Type", diff --git a/pkg-r/tests/testthat/test-querychat_tools.R b/pkg-r/tests/testthat/test-querychat_tools.R index 6c2e29b9..2770daff 100644 --- a/pkg-r/tests/testthat/test-querychat_tools.R +++ b/pkg-r/tests/testthat/test-querychat_tools.R @@ -517,6 +517,67 @@ describe("tool_update_dashboard_impl()", { describe("tool_get_schema()", { skip_if_no_dataframe_engine() + it("renders new dict fields and hides restricted columns from the card", { + df <- data.frame( + id = 1:5, + category = c("A", "B", "A", "B", "A"), + ssn = c("x", "y", "z", "w", "v"), + stringsAsFactors = FALSE + ) + df_source <- local_data_frame_source(df) + executor <- local_executor(df_source) + table_spec <- list( + name = "test_table", + constraints = list( + list(assert = "id >= 0", description = "Non-negative id") + ), + columns = list( + list( + name = "id", + description = "Primary key", + label = "Identifier", + time_zone = "UTC", + constraints = c("positive", "required"), + range = list(1, 5), + examples = as.list(1:7) + ), + list( + name = "category", + values = list(A = "Alpha", B = "Beta") + ), + list( + name = "ssn", + display = "restricted", + examples = list("000-00-0000") + ) + ) + ) + data_dicts <- list(list(tables = list(table_spec))) + schema_result <- executor$get_schema_result( + "test_table", + 20, + table_spec = table_spec + ) + text <- schema_result$text + + expect_match(text, "Label: Identifier", fixed = TRUE) + expect_match(text, "Time zone: UTC", fixed = TRUE) + expect_match(text, "Categorical values: 'A', 'B'", fixed = TRUE) + expect_match(text, "Labels: A=Alpha, B=Beta", fixed = TRUE) + expect_match(text, "… (2 more)", fixed = TRUE) + expect_match( + text, + "[restricted: do not display in user-facing output]", + fixed = TRUE + ) + expect_match(text, "Table constraints:", fixed = TRUE) + expect_match(text, "- id >= 0 (Non-negative id)", fixed = TRUE) + + # Restricted column is hidden from the user-facing card + card_html <- as.character(schema_table(schema_result$columns)) + expect_false(grepl("ssn", card_html)) + }) + it("returns schema text with a native rich display", { df_source <- local_data_frame_source(new_test_df()) executor <- local_executor(df_source) From 112d17016885a5fffc3ef063ff45f80034ff7181 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 14:57:25 -0500 Subject: [PATCH 07/13] docs(r): NEWS for data-dict context improvements --- pkg-r/NEWS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 6f942c2c..99268998 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,9 @@ # querychat (development version) +## New features + +* Data dictionaries now surface more of the [data-dict spec](https://data-dict.tidyverse.org/spec.html) to the LLM: table-level assertion `constraints`, column `label`/`time_zone`/`examples` (capped at 5), value-to-label maps (as a `Labels:` annotation, keeping category values clean for SQL), and relationship `aliases`/`conflicts`. Columns marked `display: restricted` are annotated in the LLM's schema and hidden from the user-facing schema card. (#316 follow-up) + ## Bug fixes * Data dictionaries now follow the current [data-dict spec](https://data-dict.tidyverse.org/spec.html): `tables` must be a list of tables each with a `name` field (previously a mapping of name to definition), column `range` is a two-element `[min, max]` list, and `values` may be a value-to-label map. Update older dictionary files accordingly; `data-dict validate-spec` (from the data-dict CLI) checks a file against the spec. (#316) From 1f4c8833f0e2ba628ee43ad9ac367802a3d7d3da Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 15:12:44 -0500 Subject: [PATCH 08/13] fix(r): keep R sources ASCII (R CMD check) and avoid utils::head import --- pkg-r/R/DBISource.R | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg-r/R/DBISource.R b/pkg-r/R/DBISource.R index e7cc97e8..dd9f1bed 100644 --- a/pkg-r/R/DBISource.R +++ b/pkg-r/R/DBISource.R @@ -525,9 +525,10 @@ format_schema_from_details <- function( line <- paste(line, paste0(" Labels: ", labels_str), sep = "\n") } if (length(col$examples) > 0) { - shown <- head(col$examples, 5) + shown <- col$examples[seq_len(min(5L, length(col$examples)))] more <- if (length(col$examples) > 5) { - paste0(" … (", length(col$examples) - 5, " more)") + # (\u2026 is an ellipsis; R sources must stay ASCII for R CMD check) + paste0(" \u2026 (", length(col$examples) - 5, " more)") } else { "" } From 633337c7bb94eee3e550d77a98f948268884405c Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 15:26:46 -0500 Subject: [PATCH 09/13] fix(r): apply spec values to numeric-coded columns, not just text Addresses Copilot review on #331: a values map on a numeric column (e.g. {1: Low, 2: High}) was ignored because values handling lived in the text-column branch, so the LLM lost the coding meanings. Spec values now replace live stats/categorical inference for any column kind, matching pkg-py. --- pkg-r/R/DBISource.R | 31 +++++++++++---------- pkg-r/tests/testthat/test-querychat_tools.R | 8 ++++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/pkg-r/R/DBISource.R b/pkg-r/R/DBISource.R index dd9f1bed..34f8710c 100644 --- a/pkg-r/R/DBISource.R +++ b/pkg-r/R/DBISource.R @@ -406,7 +406,9 @@ build_column_details_impl <- function( rng <- unname(spec[["range"]]) min_val <- as.character(rng[1][[1]] %||% "?") max_val <- as.character(rng[2][[1]] %||% "?") - } else { + # Spec `values` replace live stats for this column (handled below), + # so only query stats when neither `range` nor `values` is given. + } else if (is.null(spec[["values"]])) { min_key <- paste0(col, "__min") max_key <- paste0(col, "__max") if ( @@ -420,20 +422,21 @@ build_column_details_impl <- function( max_val <- as.character(column_stats[[max_key]]) } } - } else if (col %in% text_columns) { - if (!is.null(spec[["values"]])) { - vals <- spec[["values"]] - # Map form (value -> label): values are the names, labels the values - if (!is.null(names(vals))) { - value_labels <- as.list(as.character(vals)) - names(value_labels) <- names(vals) - vals <- as.list(names(vals)) - } - categories <- as.list(as.character(vals)) - } else if (col %in% names(categorical_values)) { - vals <- categorical_values[[col]] - if (length(vals) > 0) categories <- as.list(as.character(vals)) + } + # Spec `values` replace categorical inference for any column kind (a + # numeric code can have labeled values just like a text column can). + if (!is.null(spec[["values"]])) { + vals <- spec[["values"]] + # Map form (value -> label): values are the names, labels the values + if (!is.null(names(vals))) { + value_labels <- as.list(as.character(vals)) + names(value_labels) <- names(vals) + vals <- as.list(names(vals)) } + categories <- as.list(as.character(vals)) + } else if (col %in% text_columns && col %in% names(categorical_values)) { + vals <- categorical_values[[col]] + if (length(vals) > 0) categories <- as.list(as.character(vals)) } list( diff --git a/pkg-r/tests/testthat/test-querychat_tools.R b/pkg-r/tests/testthat/test-querychat_tools.R index 2770daff..ac9c0db7 100644 --- a/pkg-r/tests/testthat/test-querychat_tools.R +++ b/pkg-r/tests/testthat/test-querychat_tools.R @@ -521,6 +521,7 @@ describe("tool_get_schema()", { df <- data.frame( id = 1:5, category = c("A", "B", "A", "B", "A"), + code = c(1L, 2L, 1L, 2L, 1L), ssn = c("x", "y", "z", "w", "v"), stringsAsFactors = FALSE ) @@ -545,6 +546,10 @@ describe("tool_get_schema()", { name = "category", values = list(A = "Alpha", B = "Beta") ), + list( + name = "code", + values = list("1" = "Low", "2" = "High") + ), list( name = "ssn", display = "restricted", @@ -564,6 +569,9 @@ describe("tool_get_schema()", { expect_match(text, "Time zone: UTC", fixed = TRUE) expect_match(text, "Categorical values: 'A', 'B'", fixed = TRUE) expect_match(text, "Labels: A=Alpha, B=Beta", fixed = TRUE) + # Spec values apply to numeric-coded columns too (not just text) + expect_match(text, "Categorical values: '1', '2'", fixed = TRUE) + expect_match(text, "Labels: 1=Low, 2=High", fixed = TRUE) expect_match(text, "… (2 more)", fixed = TRUE) expect_match( text, From 012a768c2b58273df463ac4406e6d4fd3483ed82 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 15:49:19 -0500 Subject: [PATCH 10/13] fix(r): skip unused MIN/MAX aggregates when spec values replace stats Addresses Copilot review on #331: a numeric/date column with values but no range no longer uses live stats, so requesting them scanned remote tables for nothing. When all columns are documented, the aggregate query is now skipped entirely. --- pkg-r/R/DBISource.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg-r/R/DBISource.R b/pkg-r/R/DBISource.R index 34f8710c..34d3f900 100644 --- a/pkg-r/R/DBISource.R +++ b/pkg-r/R/DBISource.R @@ -294,7 +294,9 @@ build_column_details_impl <- function( c("integer", "numeric", "double", "Date", "POSIXct", "POSIXt") ) { numeric_columns <- c(numeric_columns, col) - if (is.null(spec[["range"]])) { + # Spec `range` and `values` both replace live min/max stats, so skip + # the aggregates (a wasted scan on remote tables) when either is set. + if (is.null(spec[["range"]]) && is.null(spec[["values"]])) { select_parts <- c( select_parts, paste0( From e8fd2f9fbe5de1efef201e00a28410a83dd1958b Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 16:02:20 -0500 Subject: [PATCH 11/13] docs: document phase-2 data dict fields; tighten changelog entries The data dictionary guide now covers table label/constraints, column label/time_zone/examples/display, and relationship aliases/conflicts. Changelog entries lead with what users can do rather than rendering internals. --- pkg-py/CHANGELOG.md | 2 +- pkg-py/docs/context.qmd | 7 +++++++ pkg-r/NEWS.md | 2 +- pkg-r/vignettes/context.Rmd | 7 +++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 4bf67e0c..8d913918 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features -* Data dictionaries now surface more of the data-dict spec to the LLM: table-level assertion `constraints`, column `label`/`time_zone`/`examples` (capped at 5), value-to-label maps (rendered as a `Labels:` annotation while keeping category values clean for SQL), and relationship `aliases`/`conflicts`. Columns marked `display: restricted` are annotated in the LLM's schema and hidden from the user-facing schema card. (#316 follow-up) +* Data dictionaries now support more of the data-dict spec: table-level assertion `constraints`, column `label`, `time_zone`, and `examples`, and relationship `aliases`/`conflicts`. Labels from value-to-label maps are shown to the LLM alongside the raw values, so it can interpret codes without writing them into SQL incorrectly. Columns marked `display: restricted` are hidden from the schema card shown to users (the LLM still sees them, marked as restricted). (#316 follow-up) * **Faster iterative visualization, and support for layer-level data sources.** Visualization queries now execute as a whole through `ggsql.CachingReader`: the underlying SQL runs against your data source once and the result is memoized for the chat session, so re-rendering a chart with a different encoding no longer re-queries the source (a win for remote databases especially). Visualization layers can also draw from other relations in the query — e.g. raw points from the final `SELECT` with a summary line from a CTE — via `DRAW ... MAPPING ... FROM `. Caching can be tuned or disabled with the `GGSQL_CACHE_TTL`, `GGSQL_CACHE_MAX_BYTES`, and `GGSQL_CACHE_DISABLED` environment variables. (#321) diff --git a/pkg-py/docs/context.qmd b/pkg-py/docs/context.qmd index f0d08f44..7ccc1519 100644 --- a/pkg-py/docs/context.qmd +++ b/pkg-py/docs/context.qmd @@ -84,6 +84,8 @@ glossary: Each entry under `tables` describes one table. Its `name` must match the table name you pass to `QueryChat` or `.add_table()`. - **`description`**: What this table represents (one sentence is usually enough). +- **`label`**: Optional display title, shown alongside the table in the schema view. +- **`constraints`**: Optional table-level assertions (e.g. `- assert: end_date >= start_date`), conveyed to the LLM when it fetches the table's schema. - **`columns`**: A list of column annotations. Each column can have: - `name`: Column name (must match the actual column) - `type`: Semantic type hint — `string`, `number`, `number(id)`, `number(quantity)`, `date`, `enum` @@ -91,6 +93,9 @@ Each entry under `tables` describes one table. Its `name` must match the table n - `description`: What this column means in plain English - `values`: For `enum` columns, the possible values — a list, or a value-to-label map (e.g. `{M: Male, F: Female}`) - `range`: Optional `[min, max]` bounds (either end may be `null` for an open range). Replaces live min/max queries for the column + - `label` / `time_zone`: Optional display title, and time zone for `datetime` columns — both shown in the schema view + - `examples`: Optional sample values (up to 5 are shown) + - `display`: Set to `restricted` for columns that shouldn't appear in user-facing output. The LLM still sees the column (marked as restricted) and can query it, but it's hidden from the schema card shown to users Columns listed in the data dictionary are excluded from the auto-generated schema (since your description supersedes the auto-detected metadata). Columns not listed are still auto-detected as usual. @@ -101,6 +106,8 @@ The `relationships` section tells the LLM how to join tables. Each entry has: - `description`: A plain-English description of the relationship - `cardinality`: `one-to-one`, `one-to-many`, or `many-to-one` - `join`: The join condition (e.g., `orders.customer_id = customers.id`) +- `aliases`: For self-joins, a map of the alias each side joins under to the table it refers to (e.g. `{mother: otters, pup: otters}`) +- `conflicts`: Column names that appear on both sides of the join with different meanings #### Glossary diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 99268998..1775ae62 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -2,7 +2,7 @@ ## New features -* Data dictionaries now surface more of the [data-dict spec](https://data-dict.tidyverse.org/spec.html) to the LLM: table-level assertion `constraints`, column `label`/`time_zone`/`examples` (capped at 5), value-to-label maps (as a `Labels:` annotation, keeping category values clean for SQL), and relationship `aliases`/`conflicts`. Columns marked `display: restricted` are annotated in the LLM's schema and hidden from the user-facing schema card. (#316 follow-up) +* Data dictionaries now support more of the [data-dict spec](https://data-dict.tidyverse.org/spec.html): table-level assertion `constraints`, column `label`, `time_zone`, and `examples`, and relationship `aliases`/`conflicts`. Labels from value-to-label maps are shown to the LLM alongside the raw values, so it can interpret codes without writing them into SQL incorrectly. Columns marked `display: restricted` are hidden from the schema card shown to users (the LLM still sees them, marked as restricted). (#316 follow-up) ## Bug fixes diff --git a/pkg-r/vignettes/context.Rmd b/pkg-r/vignettes/context.Rmd index 9a5fb5f3..f58c2d83 100644 --- a/pkg-r/vignettes/context.Rmd +++ b/pkg-r/vignettes/context.Rmd @@ -100,6 +100,8 @@ glossary: Each entry under `tables` describes one table. Its `name` must match the table name you pass to `QueryChat$new()` or `$add_table()`. - **`description`**: What this table represents (one sentence is usually enough). +- **`label`**: Optional display title, shown alongside the table in the schema view. +- **`constraints`**: Optional table-level assertions (e.g. `- assert: end_date >= start_date`), conveyed to the LLM when it fetches the table's schema. - **`columns`**: A list of column annotations. Each column can have: - `name`: Column name (must match the actual column) - `type`: Semantic type hint — `string`, `number`, `number(id)`, `number(quantity)`, `date`, `enum` @@ -107,6 +109,9 @@ Each entry under `tables` describes one table. Its `name` must match the table n - `description`: What this column means in plain English - `values`: For `enum` columns, the possible values — a list, or a value-to-label map (e.g. `{M: Male, F: Female}`) - `range`: Optional `[min, max]` bounds (either end may be `null` for an open range). Replaces live min/max queries for the column + - `label` / `time_zone`: Optional display title, and time zone for `datetime` columns — both shown in the schema view + - `examples`: Optional sample values (up to 5 are shown) + - `display`: Set to `restricted` for columns that shouldn't appear in user-facing output. The LLM still sees the column (marked as restricted) and can query it, but it's hidden from the schema card shown to users Columns listed in the data dictionary are excluded from the auto-generated schema (since your description supersedes the auto-detected metadata). Columns not listed are still auto-detected as usual. @@ -117,6 +122,8 @@ The `relationships` section tells the LLM how to join tables. Each entry has: - `description`: A plain-English description of the relationship - `cardinality`: `one-to-one`, `one-to-many`, or `many-to-one` - `join`: The join condition (e.g., `orders.customer_id = customers.id`) +- `aliases`: For self-joins, a map of the alias each side joins under to the table it refers to (e.g. `{mother: otters, pup: otters}`) +- `conflicts`: Column names that appear on both sides of the join with different meanings #### Glossary From acdaee12b70e1dbd1ee415203ad3d007c10a10d0 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 16:16:52 -0500 Subject: [PATCH 12/13] refactor(py): rename _apply_column_spec to apply_column_spec Addresses Copilot review on #331: module is already private, so helper names don't need a leading underscore per project convention. --- pkg-py/src/querychat/_data_dict.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/querychat/_data_dict.py b/pkg-py/src/querychat/_data_dict.py index fd7aa2ed..36fa9439 100644 --- a/pkg-py/src/querychat/_data_dict.py +++ b/pkg-py/src/querychat/_data_dict.py @@ -333,7 +333,7 @@ def get_table_schema( for meta in metas: spec = documented.get(meta.name) if spec is not None: - _apply_column_spec(meta, spec) + apply_column_spec(meta, spec) else: undocumented.append(meta) @@ -366,7 +366,7 @@ def from_yaml(cls, path: Path | str) -> DataDict: return dd -def _apply_column_spec(meta: ColumnMeta, spec: ColumnSpec) -> None: +def apply_column_spec(meta: ColumnMeta, spec: ColumnSpec) -> None: """Overlay a :class:`ColumnSpec` onto a live :class:`ColumnMeta`.""" if spec.type is not None: meta.sql_type = spec.type From 4a98f31e5a345eb4e2afc698914862065e51487a Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 16:35:34 -0500 Subject: [PATCH 13/13] docs: table label is prompt context, not rendered in the schema view Addresses Copilot review on #331. --- pkg-py/docs/context.qmd | 2 +- pkg-r/vignettes/context.Rmd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg-py/docs/context.qmd b/pkg-py/docs/context.qmd index 5f4a6cb4..933179e7 100644 --- a/pkg-py/docs/context.qmd +++ b/pkg-py/docs/context.qmd @@ -84,7 +84,7 @@ glossary: Each entry under `tables` describes one table. Its `name` must match the table name you pass to `QueryChat` or `.add_table()`. - **`description`**: What this table represents (one sentence is usually enough). -- **`label`**: Optional display title, shown alongside the table in the schema view. +- **`label`**: Optional display title for the table, included in the LLM's context. - **`constraints`**: Optional table-level assertions (e.g. `- assert: end_date >= start_date`), conveyed to the LLM when it fetches the table's schema. - **`columns`**: A list of column annotations. Each column can have: - `name`: Column name (must match the actual column) diff --git a/pkg-r/vignettes/context.Rmd b/pkg-r/vignettes/context.Rmd index 5cc65b87..bd60a37b 100644 --- a/pkg-r/vignettes/context.Rmd +++ b/pkg-r/vignettes/context.Rmd @@ -100,7 +100,7 @@ glossary: Each entry under `tables` describes one table. Its `name` must match the table name you pass to `QueryChat$new()` or `$add_table()`. - **`description`**: What this table represents (one sentence is usually enough). -- **`label`**: Optional display title, shown alongside the table in the schema view. +- **`label`**: Optional display title for the table, included in the LLM's context. - **`constraints`**: Optional table-level assertions (e.g. `- assert: end_date >= start_date`), conveyed to the LLM when it fetches the table's schema. - **`columns`**: A list of column annotations. Each column can have: - `name`: Column name (must match the actual column)