Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `resolve_database_by_id` — fetches a managed database record by id (`GET /databases/{id}`)
with no by-name fallback, and returns an already-resolved `ManagedDatabase` untouched.
`ManagedDatabase` is re-exported for callers that hold one.

- Full-text search tool backed by the engine's BM25 index. `make_hotdata_tools` grows
`search_table`/`search_column`/`search_columns`/`search_k`/`search_tool_name` and appends a
`hotdata_search_text` tool when a table and column are given; `make_hotdata_search_tool`
Expand All @@ -30,6 +34,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking: managed databases are addressed by id, never by name.** A Hotdata database name
is a display label and is not unique, so a by-name lookup can resolve to the wrong database
— and then every query, load and drop follows it there. The agent-facing
`hotdata_load_managed_table` made that reachable from an LLM, where a wrong target means a
replacing load overwrites another database's table.
- `make_hotdata_tools`, `make_hotdata_search_tool` and `make_hotdata_describe_tables_tool`
take `database_id=` in place of `database=`. It accepts an id, or a `ManagedDatabase` to
skip the lookup. The id is resolved once when the tools are built, so a bad id fails
there rather than on the agent's first query, and queries no longer pay a repeat lookup.
- The `hotdata_load_managed_table` tool's `database` argument is now `database_id`, and its
description names the two tools that hand out ids.
- `load_managed_table` takes `database_id=`; `execute_sql_json`, `bm25_search_json` and
`describe_tables_json` take a resolved `ManagedDatabase` as `database=` and raise
`TypeError` on a string, which would otherwise reach the framework's by-name fallback.
- Passing a name anywhere raises `KeyError`, naming `hotdata_list_managed_databases` as
where ids come from.

Mirrors `hotdata-dlt-destination`'s move to id-only addressing. To scope tools to the same
database as before, pass its id: `client.list_managed_databases()` reports one per database.
- Tool descriptions now state the engine's actual contract instead of a one-line summary.
`hotdata_execute_sql` names the dialect and the supported constructs, points at the search
tool for text relevance when one is registered, and warns that an aggregate query must
Expand Down
58 changes: 38 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ from langchain.agents import create_agent
import hotdata_langchain as hl

client = hl.from_env()
tools = hl.make_hotdata_tools(client, database="sales")
tools = hl.make_hotdata_tools(client, database_id="dbid...")

agent = create_agent(model=your_model, tools=tools)
result = agent.invoke(
Expand All @@ -32,8 +32,9 @@ result = agent.invoke(
print(result["messages"][-1].content)
```

Queries run against a database scope, so pass `database=` (a managed database name or id).
`hl.from_env().list_managed_databases()` shows what is available in the workspace.
Queries run against a database scope, so pass `database_id=` (a managed database id).
`hl.from_env().list_managed_databases()` shows what is available in the workspace, with the
id of each.

## Tools

Expand All @@ -42,9 +43,9 @@ Queries run against a database scope, so pass `database=` (a managed database na
| Tool | What it does |
|------|-------------|
| `hotdata_execute_sql` | Run a SQL query and return rows as JSON |
| `hotdata_list_managed_databases` | List available managed databases |
| `hotdata_create_managed_database` | Create a new managed database |
| `hotdata_load_managed_table` | Load a parquet file into a managed table |
| `hotdata_list_managed_databases` | List available managed databases, with the id of each |
| `hotdata_create_managed_database` | Create a new managed database and return its id |
| `hotdata_load_managed_table` | Load a parquet file into a managed table, addressed by database id |
| `hotdata_describe_tables` | List tables, or one table's columns and types |
| `hotdata_search_text` | Full-text search an indexed column, ranked by relevance (opt-in — see below) |

Expand All @@ -58,8 +59,8 @@ table with its column count; called with a table name it returns that table's co
types. Without it an agent has to guess column names, and a guess that misses fails the query.

```python
tools = hl.make_hotdata_tools(client, database="sales") # included
tools = hl.make_hotdata_tools(client, database="sales", describe_tables=False) # omitted
tools = hl.make_hotdata_tools(client, database_id="dbid...") # included
tools = hl.make_hotdata_tools(client, database_id="dbid...", describe_tables=False) # omitted
```

It reads `information_schema` in whichever database the tools are scoped to, so it needs no
Expand All @@ -71,19 +72,21 @@ extra permissions. With it turned off, the SQL tool's description tells the agen
You can also invoke tools outside of an agent loop:

```python
tools = {t.name: t for t in hl.make_hotdata_tools(client, database="sales")}
import json

tools = {t.name: t for t in hl.make_hotdata_tools(client, database_id="dbid...")}

result = tools["hotdata_execute_sql"].invoke({"sql": "SELECT * FROM orders LIMIT 10"})
print(result) # JSON rows

tools["hotdata_create_managed_database"].invoke({
"name": "sales",
created = tools["hotdata_create_managed_database"].invoke({
"name": "sales", # a display label, not an identifier
"schema_name": "public",
"tables": "orders,customers",
})

tools["hotdata_load_managed_table"].invoke({
"database": "sales",
"database_id": json.loads(created)["id"],
"table": "orders",
"file": "/path/to/orders.parquet",
})
Expand All @@ -96,7 +99,7 @@ Point the agent at a text column carrying a BM25 index and it gets a search tool
```python
tools = hl.make_hotdata_tools(
client,
database="sf_airbnb",
database_id="dbid...",
search_table="default.public.listings", # catalog.schema.table
search_column="description", # must have a BM25 index
search_columns=["id", "name", "price", "description"], # what each hit returns
Expand All @@ -114,7 +117,7 @@ the tool surface lets an agent discover which columns are indexed, and the engin
outright rather than falling back to a scan when a column has no BM25 index.

Inside a managed database the built-in catalog is always `default`, so a managed table reads
as `default.<schema>.<table>` when `database=` scopes the query to it.
as `default.<schema>.<table>` when `database_id=` scopes the query to it.

For more than one searchable corpus, build the tools yourself and give each a distinct name
and description — the agent then routes on the descriptions:
Expand All @@ -126,14 +129,14 @@ tools = [
# and the agent goes back to trying to match text in SQL.
*hl.make_hotdata_tools(
client,
database="sf_airbnb",
database_id="dbid...",
search_table="default.public.listings",
search_column="description",
search_tool_name="search_listings",
),
hl.make_hotdata_search_tool(
client, table="default.public.reviews", column="comments",
name="search_reviews", database="sf_airbnb",
name="search_reviews", database_id="dbid...",
),
]
```
Expand All @@ -144,12 +147,27 @@ index, then an agent that picks between search and SQL.

## Scoping queries to a managed database

`database=` resolves all SQL the agent runs against a specific managed database, by name or
id. The API requires a database scope, so queries fail with `a database is required` without
it:
`database_id=` scopes all SQL the agent runs to one managed database. The API requires a
database scope, so queries fail with `a database is required` without it:

```python
tools = hl.make_hotdata_tools(client, database_id="dbid...")
```

**Databases are addressed by id, never by name.** A database name is a display label and is
not unique, so a name lookup can silently resolve to the wrong database — and the agent's
`hotdata_load_managed_table` overwrites the table it loads into. Passing a name raises
`KeyError`. Ids come from `client.list_managed_databases()`, the
`hotdata_list_managed_databases` tool, or the response of a create.

The id is resolved once when the tools are built, so a bad id fails there rather than on the
agent's first query, and no query pays a repeat lookup. If you already hold a
`ManagedDatabase` — from `list_managed_databases()` or `create_managed_database()` — pass it
instead of its id to skip the lookup entirely:

```python
tools = hl.make_hotdata_tools(client, database="sales")
db = client.create_managed_database(description="sales", schema="public", tables=["orders"])
tools = hl.make_hotdata_tools(client, database_id=db)
```

## Controlling result size
Expand Down
34 changes: 22 additions & 12 deletions demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ The script is safe to re-run: it reuses the managed database, skips the load whe
table already has rows, and reuses an existing index.

```bash
# bind an existing managed database by id instead of letting the demo find its own
uv run --group demo --env-file .env python demo/bm25_search_demo.py \
--database-id dbid...

# different search text, more hits
uv run --group demo --env-file .env python demo/bm25_search_demo.py \
--query "quiet garden studio near the park" --k 10
Expand All @@ -45,6 +49,7 @@ LANGSMITH_TRACING=true LANGSMITH_PROJECT=hotdata-langchain-bm25 \
|---|---|---|
| `HOTDATA_API_KEY` | steps 1–5 | everything except the agent run |
| `HOTDATA_WORKSPACE` | optional | pins a workspace; first available otherwise |
| `DEMO_DATABASE_ID` | optional | pins the managed database by id (same as `--database-id`) |
| your model provider's key | step 6 | skipped without it, or without `--model` |
| `LANGSMITH_API_KEY` + `LANGSMITH_TRACING=true` | optional | traces the run to LangSmith |
| `LANGSMITH_PROJECT` | optional | project the traces land in (default: `default`) |
Expand All @@ -60,8 +65,12 @@ and SQL are both inspectable after the fact.

## What each step does

1. **Managed database** — creates `langchain_bm25_demo` with `public.listings` declared
up front, so the load materialises into it directly.
1. **Managed database** — binds the database given by `--database-id`, or else creates one
labelled `langchain_bm25_demo` with `public.listings` declared up front, so the load
materialises into it directly. Everything downstream addresses the resolved record, so
the label never selects the target; the create path prints the new id to pin. Finding a
previous run's database by its label is the one by-label lookup here, and it exists only
so the demo is re-runnable without pinning — pass `--database-id` to skip it.
2. **Listings data** — downloads the fixture parquet (cached in the system temp dir) and
loads it into the managed table.
3. **BM25 index** — creates a `bm25` index on `description` through `IndexesApi` and
Expand All @@ -83,16 +92,17 @@ and SQL are both inspectable after the fact.
Set the model with `--model` or `DEMO_MODEL`; any tool-calling model works, and the
step is skipped when its provider key is absent.

**What this step demonstrates is the routing.** Across five runs of the current
configuration the agent called the search tool and the schema tool every time, and the
final table matched the whole-neighbourhood figures every time. Two of those runs also
recovered from a bad query after the error was handed back to them.

Do not read five runs as a guarantee. Answering a compound question correctly is a
property of the model rather than of these tools, and an earlier configuration — which
shared one row budget between the search and SQL tools, silently truncating the
aggregate — got it right in only four of five. The printed tool calls are the reliable
part; treat the prose answer as illustrative.
**What this step demonstrates is the routing, and only the routing.** Across five runs
the agent called the search tool and the schema tool every time, in either order. That
part is what the tools are responsible for, and it holds.

The prose answer is a different matter: the final table matched the true
whole-neighbourhood figures in three of five runs. The two failures were the model
aggregating over the handful of listings search returned instead of over every listing
in those neighbourhoods, and a join that inflated the counts. Five runs on the merged
`main`, measured the same way against the same ground-truth query, also gave three of
five — so this is the model's ceiling on a compound question, not something these tools
introduced or can fix. Read the printed tool calls, not the table.

## What makes the agent run work

Expand Down
73 changes: 49 additions & 24 deletions demo/bm25_search_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@

PARQUET_URL = "https://www.hotdata.dev/data/sf-airbnb-listings.parquet"
USER_AGENT = "hotdata-langchain-demo"
DATABASE = "langchain_bm25_demo"
#: Display label for the database this demo creates. Not an identifier — see
#: `ensure_database`, which addresses the database by id once it has one.
DATABASE_LABEL = "langchain_bm25_demo"
SCHEMA = "public"
TABLE = "listings"
SEARCH_COLUMN = "description"
Expand Down Expand Up @@ -88,33 +90,45 @@ def download_parquet() -> Path:
return path


def find_database(client: hl.HotdataClient, name: str) -> Any | None:
def find_database_by_label(client: hl.HotdataClient, label: str) -> Any | None:
"""Scan the workspace for a database carrying this display label.

Bootstrap convenience for a re-runnable demo, and the only by-label lookup here.
Labels are not unique, so this is not how an application should find its database —
pass `--database-id` to bind one by id instead.
"""
for db in client.list_managed_databases():
if db.description == name or db.id == name:
if db.description == label:
return db
return None


def ensure_database(client: hl.HotdataClient) -> Any:
existing = find_database(client, DATABASE)
def ensure_database(client: hl.HotdataClient, database_id: str | None) -> Any:
"""Return the demo's managed database record, bound by id or created."""
if database_id:
db = hl.resolve_database_by_id(client, database_id)
print(f"Bound managed database {db.id} by id (label={db.description!r})")
return db

existing = find_database_by_label(client, DATABASE_LABEL)
if existing is not None:
print(f"Reusing managed database {DATABASE!r} (id={existing.id})")
print(f"Reusing managed database {existing.id} (label={DATABASE_LABEL!r})")
return existing
db = client.create_managed_database(description=DATABASE, schema=SCHEMA, tables=[TABLE])
print(f"Created managed database {DATABASE!r} (id={db.id}) with {SCHEMA}.{TABLE} declared")

db = client.create_managed_database(description=DATABASE_LABEL, schema=SCHEMA, tables=[TABLE])
print(f"Created managed database {db.id} (label={DATABASE_LABEL!r}) with {SCHEMA}.{TABLE}")
print(f" Pin it for later runs with --database-id {db.id} (or DEMO_DATABASE_ID)")
return db


def load_listings(client: hl.HotdataClient, parquet: Path) -> None:
loaded = client.load_managed_table(DATABASE, TABLE, schema=SCHEMA, file=str(parquet))
def load_listings(client: hl.HotdataClient, db: Any, parquet: Path) -> None:
loaded = client.load_managed_table(db, TABLE, schema=SCHEMA, file=str(parquet))
print(f"Loaded {loaded.row_count} rows into {loaded.full_name}")


def table_columns(client: hl.HotdataClient) -> list[str]:
def table_columns(client: hl.HotdataClient, db: Any) -> list[str]:
"""Return the table's column names, through the same tool the agent gets."""
described = json.loads(
hl.describe_tables_json(client, table=f"{SCHEMA}.{TABLE}", database=DATABASE)
)
described = json.loads(hl.describe_tables_json(client, table=f"{SCHEMA}.{TABLE}", database=db))
return [column["name"] for column in described["columns"]]


Expand Down Expand Up @@ -250,6 +264,12 @@ def main() -> None:
help="tool-calling model for the agent step, e.g. '<provider>:<model>' "
"(or set DEMO_MODEL); the agent step is skipped without one",
)
parser.add_argument(
"--database-id",
default=os.environ.get("DEMO_DATABASE_ID"),
help="bind an existing managed database by id (or set DEMO_DATABASE_ID); "
"without one the demo reuses or creates its own and prints the id to pin",
)
parser.add_argument("--skip-agent", action="store_true", help="stop after direct tool use")
parser.add_argument("--reload", action="store_true", help="reload the parquet even if loaded")
parser.add_argument(
Expand All @@ -261,47 +281,52 @@ def main() -> None:
print(f"Connected to {client.host} (workspace={client.workspace_id})")

if args.cleanup:
existing = find_database(client, DATABASE)
if existing is None:
print(f"No managed database named {DATABASE!r} to delete")
target = (
hl.resolve_database_by_id(client, args.database_id)
if args.database_id
else find_database_by_label(client, DATABASE_LABEL)
)
if target is None:
print(f"No managed database labelled {DATABASE_LABEL!r} to delete")
else:
client.delete_managed_database(existing.id)
print(f"Deleted managed database {DATABASE!r} (id={existing.id})")
client.delete_managed_database(target)
print(f"Deleted managed database {target.id} (label={target.description!r})")
Comment on lines +284 to +293

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the --cleanup path picked up two changes that no test and — going by the verification notes — no live run covers:

  • With --database-id, resolve_database_by_id raises KeyError on an unknown id, so the target is None branch is unreachable on that path: the user gets a traceback instead of the tidy message. And because this block sits above the try: / finally: client.close(), the client is never closed. The old find_database returned None, so cleanup always exited cleanly. Wrapping the resolve in a try/except KeyError that prints and returns would restore that.
  • delete_managed_database(target) now receives the resolved record where it previously got existing.id. Consistent with the rest of the change and presumably fine through the same resolver load_managed_table goes through, but a single --cleanup run would confirm it.

Separately, No managed database labelled {DATABASE_LABEL!r} to delete names the label even when the user pinned an id, which reads oddly on that path. (not blocking)

client.close()
return

try:
step("1. Managed database")
db = ensure_database(client)
db = ensure_database(client, args.database_id)

step("2. Listings data")
already_loaded = False
if not args.reload:
# Probing with a row read rather than COUNT(*): the engine rejects a
# projection that is aggregates only.
try:
probe = client.execute_sql(f"SELECT id FROM {TABLE_REF} LIMIT 1", database=DATABASE)
probe = client.execute_sql(f"SELECT id FROM {TABLE_REF} LIMIT 1", database=db)
already_loaded = bool(probe.rows)
if already_loaded:
print(f"{TABLE_REF} already holds data; skipping load (--reload to force)")
except Exception as e:
print(f"Table not queryable yet ({type(e).__name__}); loading fixture")
if not already_loaded:
load_listings(client, download_parquet())
load_listings(client, db, download_parquet())

step("3. BM25 index")
ensure_bm25_index(client, db.default_connection_id)

step("4. Tools")
available = table_columns(client)
available = table_columns(client, db)
columns = [c for c in PREFERRED_COLUMNS if c in available]
if SEARCH_COLUMN not in columns:
columns.append(SEARCH_COLUMN)
print(f"Table has {len(available)} columns; projecting {columns}")

tools = hl.make_hotdata_tools(
client,
database=DATABASE,
# The resolved record, so the tool set does not re-look-up what step 1 has.
database_id=db,
# Not args.k: max_rows also caps the SQL tool, and the agent's aggregate in
# step 6 groups over whole neighbourhoods, which a search-sized budget would
# silently truncate. Search hits and SQL rows are different budgets.
Expand Down
Loading
Loading