Skip to content

Releases: jay123anta/jeeves

v1.2.0 -Answers that span two tables

Choose a tag to compare

@jay123anta jay123anta released this 14 Sep 06:00

Jeeves 1.2.0

Every change in this release came from pointing the package at a database it had never seen - the public Chinook music store, installed from the packaged archive into a fresh Laravel app - and asking it questions.

Questions that span two tables are answered, not guessed

"How many albums does Iron Maidan have?" was placed on the album table, searched for the artist in the album-title column, and answered a confident 0. On a schema whose tables are linked, a name that is not stored in the column it was filtered on now goes to SQL generation, which can join to the table it belongs to. It answers 21. A name that is stored there, or a word from the schema misread into the name slot, is handled as before at no extra call.

"Top 3 genres by revenue" came back as "What metric would you like?", with the revenue two joins away. A ranking by a named measure now counts as naming its measure. "Which is the best?" names nothing and is still asked.

A misspelled value is corrected instead of answered "no data"

correct_typos on a column: when a filter on it finds nothing, the value typed is compared with the values the column holds, and one clearly closest is swapped in for one more run. The stored values are read and compared on your server and never reach the model, and correcting costs no provider call. Each correction is reported in metadata.value_corrections.

Other names for a stored value

value_aliases on any column: a rename, a spelling variant, a synonym. "United States" reaches rows stored as USA. Exact matches only, scoped to the column that declares them, on both routes, and reported in metadata.value_aliases_applied.

Pinned queries

pinned_queries ties an exact question to reviewed SQL that runs with no model call and the same answer every time. It is still validated like any other statement, and nothing rewrites it. It buys stability for the questions that must never vary - and says nothing about accuracy on the questions nobody pinned.

Misspelled dataset names, without a call

fuzzy_dataset_matching, off by default. A question whose words are an edit or two from one dataset's name is routed there locally. Ties are left to the model rather than guessed.

jeeves:benchmark can check an answer, not only compare it

A question may carry expect - a row count, a range, a name that must appear. It closes the case where a SUM over no rows returns NULL, the reference returns the same NULL, and the two compare as correct. A clarification is now reported as the question the model asked, not a bare "no answer".

Fixed

  • A conversation's filter reaches the SQL that runs. "Revenue by billing country""only USA""break that down by year" answered every country's revenue by year, under a state line still reading "BillingCountry is USA". The model had written the USA filter; the SQL verifier, shown only the last sentence, removed it as "not requested". The verifier is now told which filters the conversation is under, and every statement is checked where it runs: one that leaves out a filter still in force is asked for again once, and if it is still missing the answer begins "Not limited to …". A statement that keeps the filter costs no extra call. This was in 1.1.0 too.
  • "Top N … (highest)" is said only of rows ranked that way. A year-by-year series, ordered by year, was captioned "Top 5 by revenue (highest): 2009, 2010, 2011" when 2010 was the highest. The direction is now read from the rows, and rows ordered by something else are shown as a list of values. This was in 1.1.0 too.
  • A conversation keeps an answer the model's SQL produced. Through the chat endpoint, "Top 3 genres by revenue" ran correct SQL and showed "I don't have a measure called Revenue" - the model's own name for the column it computed. The answer now stands, and the conversation keeps that measure, so a follow-up like "only Rock" goes to SQL generation rather than quietly becoming a count. This was in 1.1.0 too.
  • A single named record runs on MySQL in strict mode. "Only Rock" built a query with COUNT(*) beside a label and no GROUP BY, which Laravel's default MySQL strict mode refuses. Found on MariaDB. This was in 1.1.0 too.
  • No invented unit. A measure with no declared unit read "481.45 units"; it now shows no unit.
  • The answer sentence names what ran. "21: N/A (Counts the number of albums by…)" and "Top 3 by This query retrieves the top 3 genres…" - right numbers, wrong sentences. The measure is now read from the executed row and named from that column, and the model's prose explanation is no longer used as a measure's name.
  • "Which X has the most Y" keeps its number. The one-row retry sometimes returned only the name, with the count left in the ORDER BY. That aggregate is put back into the SELECT locally - no second call, and validated like any other statement. A row with no measure is answered with its label, not a caption about a count that never ran.
  • query_mode_used names who wrote the SQL. A retry that regenerated SQL through the model was still reported as intent.
  • Readable wording. "Total Number of album: 1 records" now reads "Number of albums: 1".
  • jeeves:discover no longer offers a foreign key as a measure. Existing files keep what they say; see the upgrade note.

Nothing in the package assumes a place

Examples, comments and one legacy field still carried the geography of the project this package began in. They are gone. For custom LLM providers: return a single-record filter as group_value; the old district key is no longer read. Every bundled provider already returned group_value.

Measured

On Chinook, live on Gemini 2.5 Flash, with a curated schema: 14/14 on a 14-question set, up from 12/14 before these fixes, over repeated runs. The packaged release was also installed into a fresh Laravel app and run on SQLite and on MariaDB 10.4 through a SELECT-only user, where jeeves:doctor proved the connection cannot create tables. That is a small set on one database and one model; it shows the fixes work, not what your accuracy will be. The Spider and own-set numbers published with 1.1.0 were not re-measured for this release, and three-table joins remain the weak spot they describe. php artisan jeeves:benchmark runs against your own schema and reference SQL.

Upgrading from 1.1

No migrations. Every new setting is off unless you enable it.

  • Schema files jeeves:discover wrote earlier may still mark foreign-key columns aggregatable and sortable. --merge keeps them, because it preserves curation - delete those two keys from *_id columns by hand, or regenerate uncurated files with --force.
  • A custom provider returning district instead of group_value loses that filter.
  • Answer sentences are worded differently. If you assert on answer text, expect changes; rows and parsed_query are unchanged.
  • mergeConfigFrom is one level deep: php artisan jeeves:doctor reports any setting this version ships that your published config lacks.
composer require jayanta/jeeves:^1.2

v1.1.0 - Runs on Laravel 11

Choose a tag to compare

@jay123anta jay123anta released this 10 Sep 18:10

Runs on Laravel 11

The constraint is ^11.0|^12.0|^13.0 and CI tests all three. Laravel 11 is
past security support, so every published 11.x carries advisories that will
never be fixed and recent Composer refuses to resolve them by default. If your
composer require is blocked, the README says why and what to set — the block
comes from your framework, not from this package, and it applies to every
package you install.

It asks again when the shape of an answer contradicts the question

"Which carrier shipped the most orders" has one answer. Models routinely write
it without the LIMIT, so the top row is right and every row under it is wrong.
Nothing about that is visible before execution, which is why the SQL verifier
cannot catch it — the SQL is valid.

The row count is now inspected on your own server and, when a question that
asks for one thing came back as a list, the query is regenerated once. The retry
prompt carries the question, the schema and one sentence about the shape. No
value, no row, no count.

Semantic dataset matching, off by default

An optional stage between keyword routing and the model: an embedding service
you run is asked which dataset a question is closest to, and a confident answer
skips the model call that would otherwise be spent just placing it.

No model or container ships with this — the client is about 10 KB and adds
no dependency. Exact routing always wins, and a miss, a timeout or an outage
leaves the question on exactly the route it takes today. It can add a route,
never remove one. See semantic_matching in the config.

php artisan jeeves:semantic-corpus writes the descriptions such a service
ranks against, generated from your own schema files — orders and tickets in one
application, patients and claims in another. Nothing about the matching is
specific to any domain, and no corpus is bundled, because a shipped one would
describe nobody's data. It writes names, descriptions and the aliases your users
type, and never opens a database connection.

Fixed

  • The security event raised for rejected SQL carried an empty question, so the
    one record of an unsafe generation did not say what had been asked.
  • Feedback corrections are screened again where they are replayed into a prompt,
    not only where they are submitted — rows written before that check existed
    were reaching prompts unscreened.

Where it is still weak

Measured, not estimated, and unchanged by this release:

Benchmark Score
Spider dev sample 30-31/36 (83-86%)
This package's own set 35/46 (76%)

By join width on that second set:

Join width Correct
1 table 16-17/20
2 tables 13/16
3 tables 2/6
4 tables 3-4/4

Three-table joins are the honest weak spot, and they fail consistently rather
than occasionally. It is not a missing-information problem — the prompt carries
the full join path, table by table, and the model still writes the wrong query.
Curating schema descriptions does not fix it either. If your questions are
mostly three-table shapes, measure before you commit:
php artisan jeeves:benchmark runs against your own schema and your own
reference SQL.

Provider conformance, 17 checks per model: Gemini 2.5 Flash, Claude Sonnet 5,
DeepSeek v4 Flash, Mistral Large and the open-weights gpt-oss-20b all scored
17/17. Muse Glimmer 30B and Nemotron 3 Super 120B scored 15/17. A 20B model
passing everything, including multi-step decomposition, is the result worth
knowing: the conversation features are resolved in PHP rather than asked of the
model, which is why they hold up on small local models.

Upgrading from 1.0.0

No breaking changes and no migrations. Semantic matching is off unless you
enable it, so an install that changes nothing behaves exactly as before.

If you published the config before upgrading, note that Laravel's
mergeConfigFrom is one level deep — php artisan jeeves:doctor reports any
setting this version ships that your published config does not have.

composer require jayanta/jeeves:^1.1

v1.0.0 - a chat window for your own database

Choose a tag to compare

@jay123anta jay123anta released this 07 Sep 07:05

A chat window for your own database

<x-jeeves::widget /> puts a conversation thread in any Blade view: users ask
in plain English, and the answer comes back as a number, a table or a chart.
Follow-ups work — ask "revenue by city", then "just Guwahati", then
"break that down by client", and rewind. That state is resolved in PHP rather
than asked of the model, so it holds even on small local models.

Voice input is browser-side. No audio reaches your server.

There is a programmatic API too — Jeeves::query("top 5 customers by revenue")
— and an HTTP contract for front ends that are not Blade.

The model never sees a row

Only schema structure is sent: table names, column names, types, and the words
your users use for them. The model returns SQL; your server validates it, runs
it locally, and formats the result. Not one row of data goes upstream, and
that is enforced by tests rather than by intention — PrivacyWallTest seeds
sentinel values, runs real queries end to end, and asserts those values appear
nowhere in anything sent to a provider.

Generated SQL runs on a connection that cannot write

sql.database_connection is required, and it must not be your
application's default. Point it at a database user holding SELECT and nothing
else. The check is on the resolved connection at execution time, not at a call
site, and there is no fallback path anywhere in the package.

Above it sit three more layers: an input guard, a schema-only prompt, and a
validator with a function allowlist and a schema-derived table whitelist.
docs/SECURITY.md states what each one does and — just as importantly — what
it does not cover. php artisan jeeves:doctor checks the lot, and on MySQL and
PostgreSQL proves the connection is read-only by trying to create a table on it.

See docs/CONNECTION.md for the GRANT statements and
statement timeouts.

Any model, hosted or your own

Gemini, OpenAI, Claude, DeepSeek, Groq, OpenRouter — or a model you run
yourself on Ollama, vLLM, LM Studio or llama.cpp. One config block, no code
changes. PostgreSQL, MySQL, MariaDB and SQLite.

Measured, including where it is weak

812 tests and CI across five database engines. Accuracy is published with the
failures included, because a text-to-SQL tool that only quotes its good number
is not one you can plan around:

Benchmark Score
This package's own 14-table set 35/46 (76%)
Spider dev sample 30-31/36 (83-86%)

Three consecutive runs each, uncurated, against Gemini 2.5 Flash. The weak
spot is not join depth as such — three-table questions scored 2/6 in every
run while four-table ones scored 3-4/4. Superlatives and projection are the
other two known classes. README.md names them and shows how to measure your
own schema.