Files
Allen HouchinsandEric 19af21dd1a Upgrade query-generator SQL step to Claude Sonnet 5 (#49187)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** N/A

## What this does

The `/query-generator` page's osquery-SQL-generation step
([get-llm-generated-sql.js](website/api/controllers/query-generator/get-llm-generated-sql.js))
was on `claude-sonnet-4-6`, which is now one generation behind. This PR:

- Bumps that call to `claude-sonnet-5`. The schema-filtration step stays
on `claude-haiku-4-5`, which is already the latest Haiku release, so no
change needed there.
- Adds `effort` support to the shared [`ai.prompt`
helper](website/api/helpers/ai/prompt.js), forwarded as
`output_config.effort` on Anthropic requests, and sets it to `"low"` for
the SQL-generation call. Effort controls how much the model deliberates
(and how many tokens/how much latency that costs). `"low"` was chosen
because the Haiku pre-filtering step already narrows the osquery schema
down to relevant tables, so the Sonnet step isn't starting from scratch
and doesn't need to spend much effort re-deriving that context.
- Bumps `max_tokens` in the Anthropic branch of the helper from 4096 to
8192. Claude Sonnet 5 turns on adaptive thinking by default when the
`thinking` param is omitted (which this helper does), and `max_tokens`
is a hard cap on *total* output including thinking tokens — at 4096
there was a real risk of thinking tokens eating into the budget and
truncating the JSON response the SQL step needs to return.
- **Fixes a pre-existing bug found while making the above changes:** the
`sqlReport` call passed the system prompt as a bare object-shorthand key
named `systemPromptForQueryGeneration`, but the `ai.prompt` helper's
declared input is `systemPrompt`. Sails silently drops unrecognized keys
passed to `.with(...)`, so the "Return ONLY a raw JSON object..." system
prompt was never actually reaching the model for this call. This has
been broken since the query generator was switched to Anthropic
(`f7c20c4731`); the sibling `filteredTables` call above it was
unaffected since it passes `systemPrompt` positionally. Now fixed to
`systemPrompt: systemPromptForQueryGeneration`.

## Why

Claude Sonnet 5 follows structured/constrained instructions (don't alias
tables, use `LIKE` with wildcards, only reference documented columns,
etc.) more literally than 4.6, which should make the generated SQL more
reliable. It's priced the same or cheaper than 4.6 during the current
introductory period.

## Trade-offs called out for review

- Thinking being on by default adds some latency versus the old
(thinking-off) behavior on 4.6. This call is not currently streamed
(`sails.helpers.http.post`, single blocking call over a socket), so any
added thinking time is invisible wait time for the user rather than a
visible "thinking" indicator. `effort: "low"` should keep this modest,
but worth confirming with a manual QA pass on a few representative
questions before merging.
- Only the SQL-generation call was migrated. The schema-filtration call
also runs on an Anthropic model, but Haiku 4.5 doesn't support
`output_config.effort` (added `effort` is a no-op if passed to it), so
it was left as-is.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [ ] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [ ] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
  * Improved AI-generated SQL responses with an updated language model.
  * Added adaptive effort controls for supported AI requests.
* Increased response capacity to support more detailed generated
results.
* Improved handling of AI responses to provide more reliable results
when content includes different response formats.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Eric <eashaw@sailsjs.com>
2026-08-03 21:39:40 -05:00

210 lines
9.2 KiB
JavaScript
Vendored

module.exports = {
friendlyName: 'Get llm generated sql',
description: '',
inputs: {
naturalLanguageQuestion: { type: 'string', required: true }
},
exits: {
success: {
description: 'A SQL query was generated'
},
couldNotGenerateQueries: {
description: 'A set of queries could not be generated for a user using the provided question.',
responseType: 'badRequest'
}
},
fn: async function ({naturalLanguageQuestion}) {
// Generate a random room name.
let roomId = await sails.helpers.strings.random();
if(this.req.isSocket) {
// Add the requesting socket to the room.
sails.sockets.join(this.req, roomId);
}
let completeTables = sails.config.builtStaticContent.schemaTables;
let prunedTables = completeTables.map((table)=>{
let newTable = _.pick(table,['name','description','platforms', 'examples']);
newTable.columns = table.columns.map((column) => _.pick(column, ['name', 'description', 'type', 'platforms', 'required']));
return newTable;
});
// Filter down the schema.
let schemaFiltrationPrompt = `Given this question from an IT admin, and using the provided context (the osquery schema), return the subset of tables that might be relevant for designing an osquery SQL query to answer this question for computers running macOS, Windows, Linux, and/or ChromeOS.
Here is the question:
\`\`\`
${naturalLanguageQuestion}
\`\`\`
Provided context:
\`\`\`
${JSON.stringify(prunedTables.map((table)=>{
let lighterTable = _.pick(table, ['name','description','platforms']);
lighterTable.columns = table.columns.map((column)=>{
let lighterColumn = _.pick(column, ['name', 'description', 'platforms']);
return lighterColumn;
});
return lighterTable;}))}
\`\`\`
Please respond in JSON, with the same data shape as the provided context, but with the array filtered to include only relevant tables.
If no queries can be generated from the provided instructions do not return the datashape above and instead return this JSON in this exact data shape:
{
"couldNotGenerateQueries": true
}`;
let systemPromptForQueryGeneration = 'Return ONLY a raw JSON object.'+
'Do not include ```json, ```, or any markdown formatting.'+
'Do not include any explanation or text before or after the JSON.'+
'Your entire response must be valid JSON.';
let filteredTables = await sails.helpers.ai.prompt(schemaFiltrationPrompt, 'claude-haiku-4-5', true, systemPromptForQueryGeneration)
.intercept((err)=>{
sails.log.warn(`When trying to get a subset of tables to use to generate a query for a user, an error occurred. Full error: ${require('util').inspect(err, {depth: 2})}`);
if(this.req.isSocket){
// If this request was from a socket and an error occurs, broadcast an 'error' event and unsubscribe the socket from this room.
sails.sockets.broadcast(roomId, 'error', {error: err});
sails.sockets.leave(this.req, roomId);
}
return 'couldNotGenerateQueries';
});
if(filteredTables.couldNotGenerateQueries){
if(this.req.isSocket){
sails.sockets.broadcast(roomId, 'error', {error: 'couldNotGenerateQueries'});
sails.sockets.leave(this.req, roomId);
} else {
throw 'couldNotGenerateQueries';
}
}
// 2024-02-26: Testing using a system prompt with a single API request.
// let systemPrompt = `You are an AI that generates osquery SQL queries for IT admin questions. Use the following osquery schema as context:
// \`\`\`
// ${JSON.stringify(prunedTables.map((table)=>{
// let lighterTable = _.pick(table, ['name','description','platforms']);
// lighterTable.columns = table.columns.map((column)=>{
// let lighterColumn = _.pick(column, ['name', 'description', 'platforms']);
// return lighterColumn;
// });
// return lighterTable;}))}
// \`\`\`
// When generating the SQL:
// 1. Please do not use the SQL "AS" operator, nor alias tables. Always reference tables by their full name.
// 2. If this question is related to an application or program, consider using LIKE instead of something verbatim.
// 3. If this question is not possible to ask given the tables and columns available in the provided context (the osquery schema) for a particular operating system, then use empty string.
// 4. If this question is a "yes" or "no" question, or a "how many people" question, or a "how many hosts" question, then build the query such that a "yes" returns exactly one row and a "no" returns zero rows. In other words, if this question is about finding out which hosts match a "yes" or "no" question, then if a host does not match, do not include any rows for it.
// 5. Use only tables that are supported for each target platform, as documented in the provided context, considering the examples if they exist, and the available columns.
// 6. For each table that you use, only use columns that are documented for that table, as documented in the provided context.`;
// let sqlPrompt = `Given this question from an IT admin, return osquery SQL I could run on a computer (or fleet of computers) to answer this question.
// Here is the question:
// \`\`\`
// ${naturalLanguageQuestion}
// \`\`\`
// Please give me all of the above in JSON, with this data shape:
// {
// "macOSQuery": "TODO",
// "windowsQuery": "TODO",
// "linuxQuery": "TODO",
// "chromeOSQuery": "TODO",
// "macOSCaveats": "TODO",
// "windowsCaveats": "TODO",
// "linuxCaveats": "TODO",
// "chromeOSCaveats": "TODO",
// }`;
// Now generate the SQL.
let sqlPrompt = `Given this question from an IT admin, return osquery SQL I could run on a computer (or fleet of computers) to answer this question.
Here is the question:
\`\`\`
${naturalLanguageQuestion}
\`\`\`
When generating the SQL:
1. Please do not use the SQL "AS" operator, nor alias tables. Always reference tables by their full name.
2. When generating a query that uses the "LIKE" operator, you should include wildcard characters.
3. If this question is related to an application or program, consider using LIKE instead of something verbatim.
4. If this question is not possible to ask given the tables and columns available in the provided context (the osquery schema) for a particular operating system, then use empty string.
5. If this question is a "yes" or "no" question, or a "how many people" question, or a "how many hosts" question, then build the query such that a "yes" returns exactly one row and a "no" returns zero rows. In other words, if this question is about finding out which hosts match a "yes" or "no" question, then if a host does not match, do not include any rows for it.
6. Use only tables that are supported for each target platform, as documented in the provided context, considering the examples if they exist, and the available columns.
7. For each table that you use, only use columns that are documented for that table, as documented in the provided context.
Provided context:
\`\`\`
${JSON.stringify(filteredTables)}
\`\`\`
Please give me all of the above in JSON, with this data shape:
{
"macOSQuery": "TODO",
"windowsQuery": "TODO",
"linuxQuery": "TODO",
"chromeOSQuery": "TODO",
"macOSCaveats": "TODO",
"windowsCaveats": "TODO",
"linuxCaveats": "TODO",
"chromeOSCaveats": "TODO",
}
If no queries can be generated from the provided instructions do not return the datashape above and instead return this JSON in this exact data shape:
{
"couldNotGenerateQueries": true
}`;
// Effort is set to "low" because the schema was already narrowed down to relevant tables by the
// schema-filtration step above -- the model doesn't need to spend much effort re-deriving that context.
let sqlReport = await sails.helpers.ai.prompt.with({prompt:sqlPrompt, baseModel:'claude-sonnet-5', expectJson: true, systemPrompt: systemPromptForQueryGeneration, effort: 'low'})
.intercept((err)=>{
if(this.req.isSocket){
// If this request was from a socket and an error occurs, broadcast an 'error' event and unsubscribe the socket from this room.
sails.sockets.broadcast(roomId, 'error', {error: err});
sails.sockets.leave(this.req, roomId);
}
sails.log.warn(`When trying to generate a query for a user, an error occurred. Full error: ${require('util').inspect(err, {depth: 2})}`);
return 'couldNotGenerateQueries';
});
if(sqlReport.couldNotGenerateQueries){
if(this.req.isSocket){
sails.sockets.broadcast(roomId, 'error', {error: 'couldNotGenerateQueries'});
sails.sockets.leave(this.req, roomId);
} else {
throw 'couldNotGenerateQueries';
}
}
// If this request was from a socket, we'll broadcast a 'queryGenerated' event with the sqlReport and unsubscribe the socket
if(this.req.isSocket){
sails.sockets.broadcast(roomId, 'queryGenerated', {result: sqlReport});
sails.sockets.leave(this.req, roomId);
} else {
// Otherwise, return the JSON sqlReport.
return sqlReport;
}
}
};