Skip to content

Commit 03953f0

Browse files
committed
feat: add Nsql and NsqlGenerateSqlAsync for the runtime's /v1/nsql endpoint
Adds SpiceClient.NsqlAsync and NsqlGenerateSqlAsync, wrapping the runtime's natural-language-to-SQL endpoint. NsqlAsync runs the generated query and returns the rows alongside the SQL; NsqlGenerateSqlAsync only translates, letting the caller inspect or run it themselves via Query. gospice already exposes this; this brings the .NET SDK in line.
1 parent c40e119 commit 03953f0

6 files changed

Lines changed: 683 additions & 0 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,30 @@ matched column values in `Matches`, the row's `PrimaryKey`, the columns requeste
349349
`AdditionalColumns` in `Data`, and any `Metadata`. The runtime omits the last three when
350350
empty; they default to empty dictionaries, so they can be read without a null check.
351351

352+
#### NSQL
353+
354+
`NsqlAsync` answers a natural-language question by having the runtime's configured LLM
355+
generate SQL, then running it. `NsqlGenerateSqlAsync` translates the question into SQL
356+
without running it. Both require an LLM model configured in the Spicepod.
357+
358+
```csharp
359+
using Spice;
360+
using Spice.Nsql;
361+
362+
using var client = new SpiceClientBuilder().Build();
363+
364+
var result = await client.NsqlAsync(new NsqlRequest("top 5 customers by revenue"));
365+
366+
Console.WriteLine(result.SQL);
367+
foreach (var row in result.Data)
368+
{
369+
Console.WriteLine(row["customer_id"]);
370+
}
371+
372+
// Or generate the SQL without running it
373+
var sql = await client.NsqlGenerateSqlAsync(new NsqlRequest("how many orders"));
374+
```
375+
352376
### Memory Management
353377

354378
The `SpiceClient` implements `IDisposable` and should be properly disposed to release network resources (gRPC channels, HTTP clients). Use the `using` statement or `using` declaration for automatic disposal:

Spice/src/Http/ISpiceHttpClient.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
*/
2222

2323
using Spice.Datasets;
24+
using Spice.Nsql;
2425
using Spice.Search;
2526

2627
namespace Spice.Http;
@@ -85,4 +86,28 @@ public interface ISpiceHttpClient : IDisposable
8586
/// <exception cref="System.ArgumentException">Thrown when the search text is null or empty</exception>
8687
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
8788
Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default);
89+
90+
/// <summary>
91+
/// Answers a natural-language query by having the runtime's configured LLM generate SQL,
92+
/// then running it, via the <c>/v1/nsql</c> endpoint.
93+
/// </summary>
94+
/// <param name="request">The natural-language query to answer</param>
95+
/// <param name="cancellationToken">Token to cancel the request</param>
96+
/// <returns>The generated SQL alongside the rows it returned</returns>
97+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
98+
/// <exception cref="System.ArgumentException">Thrown when the query text is null or empty</exception>
99+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
100+
Task<NsqlResponse> NsqlAsync(NsqlRequest request, CancellationToken cancellationToken = default);
101+
102+
/// <summary>
103+
/// Translates a natural-language query into SQL without running it, via the
104+
/// <c>/v1/nsql</c> endpoint.
105+
/// </summary>
106+
/// <param name="request">The natural-language query to translate</param>
107+
/// <param name="cancellationToken">Token to cancel the request</param>
108+
/// <returns>The generated SQL</returns>
109+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
110+
/// <exception cref="System.ArgumentException">Thrown when the query text is null or empty</exception>
111+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
112+
Task<string> NsqlGenerateSqlAsync(NsqlRequest request, CancellationToken cancellationToken = default);
88113
}

Spice/src/Http/SpiceHttpClient.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2626
using Spice.Auth;
2727
using Spice.Common;
2828
using Spice.Datasets;
29+
using Spice.Nsql;
2930
using Spice.Search;
3031

3132
namespace Spice.Http;
@@ -282,6 +283,102 @@ public async Task<SearchResponse> SearchAsync(SearchRequest request, Cancellatio
282283
return JsonSerializer.Deserialize<SearchResponse>(body, SearchJsonOptions) ?? new SearchResponse();
283284
}
284285

286+
/// <summary>
287+
/// Asks the runtime for the envelope carrying the generated SQL alongside the results.
288+
/// Without it, <c>/v1/nsql</c> returns a bare array of rows and the generated SQL is lost.
289+
/// </summary>
290+
private const string NsqlJsonMediaType = "application/vnd.spiceai.nsql.v1+json";
291+
292+
/// <summary>
293+
/// Asks the runtime to generate SQL without executing it.
294+
/// </summary>
295+
private const string NsqlSqlMediaType = "application/sql";
296+
297+
/// <summary>
298+
/// Options used to serialize NSQL requests and deserialize NSQL responses.
299+
/// </summary>
300+
private static readonly JsonSerializerOptions NsqlJsonOptions = new()
301+
{
302+
PropertyNamingPolicy = null,
303+
};
304+
305+
/// <summary>
306+
/// Answers a natural-language query by having the runtime's configured LLM generate SQL,
307+
/// then running it, via the <c>/v1/nsql</c> endpoint.
308+
/// </summary>
309+
/// <param name="request">The natural-language query to answer</param>
310+
/// <param name="cancellationToken">Token to cancel the request</param>
311+
/// <returns>The generated SQL alongside the rows it returned</returns>
312+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
313+
/// <exception cref="System.ArgumentException">Thrown when the query text is null or empty</exception>
314+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
315+
public async Task<NsqlResponse> NsqlAsync(NsqlRequest request, CancellationToken cancellationToken = default)
316+
{
317+
var body = await DoNsqlRequestAsync(request, NsqlJsonMediaType, cancellationToken).ConfigureAwait(false);
318+
return JsonSerializer.Deserialize<NsqlResponse>(body, NsqlJsonOptions) ?? new NsqlResponse();
319+
}
320+
321+
/// <summary>
322+
/// Translates a natural-language query into SQL without running it, via the
323+
/// <c>/v1/nsql</c> endpoint.
324+
/// </summary>
325+
/// <param name="request">The natural-language query to translate</param>
326+
/// <param name="cancellationToken">Token to cancel the request</param>
327+
/// <returns>The generated SQL</returns>
328+
/// <exception cref="System.ArgumentNullException">Thrown when request is null</exception>
329+
/// <exception cref="System.ArgumentException">Thrown when the query text is null or empty</exception>
330+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
331+
public async Task<string> NsqlGenerateSqlAsync(NsqlRequest request, CancellationToken cancellationToken = default)
332+
{
333+
var body = await DoNsqlRequestAsync(request, NsqlSqlMediaType, cancellationToken).ConfigureAwait(false);
334+
return body.Trim();
335+
}
336+
337+
/// <summary>
338+
/// Posts <paramref name="request"/> to <c>/v1/nsql</c> asking for <paramref name="accept"/>,
339+
/// and returns the response body when the runtime answered with success.
340+
/// </summary>
341+
private async Task<string> DoNsqlRequestAsync(NsqlRequest request, string accept, CancellationToken cancellationToken)
342+
{
343+
#if NET8_0_OR_GREATER
344+
ObjectDisposedException.ThrowIf(_disposed, this);
345+
ArgumentNullException.ThrowIfNull(request);
346+
ArgumentException.ThrowIfNullOrWhiteSpace(request.Query, $"{nameof(request)}.{nameof(request.Query)}");
347+
#else
348+
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
349+
ThrowHelper.ThrowIfNull(request, nameof(request));
350+
ThrowHelper.ThrowIfNullOrWhiteSpace(request.Query, $"{nameof(request)}.{nameof(request.Query)}");
351+
#endif
352+
353+
var url = $"{_httpAddress}/v1/nsql";
354+
var json = JsonSerializer.Serialize(request, NsqlJsonOptions);
355+
356+
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
357+
{
358+
Content = new StringContent(json, Encoding.UTF8, "application/json"),
359+
};
360+
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
361+
362+
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
363+
364+
#if NET8_0_OR_GREATER
365+
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
366+
#else
367+
// netstandard2.0 has no CancellationToken overload for ReadAsStringAsync.
368+
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
369+
#endif
370+
371+
if (!response.IsSuccessStatusCode)
372+
{
373+
// The runtime explains NSQL failures in the body - a missing or ambiguous model,
374+
// or SQL that would not run. Surface it rather than only the status code.
375+
throw new HttpRequestException(
376+
$"NSQL request failed with status {(int)response.StatusCode}: {ExtractErrorMessage(body)}");
377+
}
378+
379+
return body;
380+
}
381+
285382
/// <summary>
286383
/// Pulls the runtime's error message out of a failed response body, falling back to
287384
/// the raw body when it is not the expected shape.

Spice/src/Nsql/NsqlTypes.cs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
Copyright 2024 The Spice.ai OSS Authors
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in all
12+
copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
SOFTWARE.
21+
*/
22+
23+
using System.Text.Json;
24+
using System.Text.Json.Serialization;
25+
26+
namespace Spice.Nsql;
27+
28+
/// <summary>
29+
/// A natural-language query against the runtime's <c>/v1/nsql</c> endpoint.
30+
/// </summary>
31+
/// <remarks>
32+
/// Only <see cref="Query"/> is required. The runtime needs an LLM model configured in the
33+
/// Spicepod to translate it; when exactly one is configured, <see cref="Model"/> may be left
34+
/// unset and the runtime selects it.
35+
/// </remarks>
36+
public class NsqlRequest
37+
{
38+
/// <summary>
39+
/// Creates an NSQL request.
40+
/// </summary>
41+
/// <param name="query">The question to answer, in natural language</param>
42+
public NsqlRequest(string query)
43+
{
44+
Query = query;
45+
}
46+
47+
/// <summary>
48+
/// The question to answer, in natural language. Required.
49+
/// </summary>
50+
[JsonPropertyName("query")]
51+
public string Query { get; set; }
52+
53+
/// <summary>
54+
/// Names the LLM used to generate SQL. When unset, the runtime uses the only compatible
55+
/// model configured in the Spicepod, and reports an error if there is not exactly one.
56+
/// </summary>
57+
[JsonPropertyName("model")]
58+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
59+
public string? Model { get; set; }
60+
61+
/// <summary>
62+
/// Hints which datasets to sample when building model context. This is a sampling hint
63+
/// only - it does not restrict which tables the generated query may reference. When
64+
/// unset, all datasets are used.
65+
/// </summary>
66+
[JsonPropertyName("datasets")]
67+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
68+
public IReadOnlyList<string>? Datasets { get; set; }
69+
70+
/// <summary>
71+
/// Includes sample rows in the context given to the model. It improves generation on
72+
/// ambiguous schemas at the cost of sending data values to the model.
73+
/// </summary>
74+
[JsonPropertyName("sample_data_enabled")]
75+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
76+
public bool SampleDataEnabled { get; set; }
77+
78+
/// <summary>
79+
/// A stable key forwarded to the model provider for prompt caching. Reuse it across
80+
/// related requests to benefit from it.
81+
/// </summary>
82+
[JsonPropertyName("prompt_cache_key")]
83+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
84+
public string? PromptCacheKey { get; set; }
85+
}
86+
87+
/// <summary>
88+
/// Describes one column of an <see cref="NsqlResponse"/>.
89+
/// </summary>
90+
public class NsqlField
91+
{
92+
/// <summary>
93+
/// The column name.
94+
/// </summary>
95+
[JsonPropertyName("name")]
96+
public string Name { get; set; } = string.Empty;
97+
98+
/// <summary>
99+
/// The column's Arrow type in its JSON encoding. Simple types encode as a quoted string
100+
/// (<c>"Utf8"</c>, <c>"Int64"</c>); parameterized ones as an object (for example
101+
/// <c>{"Timestamp":["Nanosecond",null]}</c>), which is why this is captured as raw JSON
102+
/// rather than a fixed shape.
103+
/// </summary>
104+
[JsonPropertyName("data_type")]
105+
public JsonElement DataType { get; set; }
106+
107+
/// <summary>
108+
/// Whether the column admits nulls.
109+
/// </summary>
110+
[JsonPropertyName("nullable")]
111+
public bool Nullable { get; set; }
112+
}
113+
114+
/// <summary>
115+
/// The schema of the rows an NSQL call returned.
116+
/// </summary>
117+
/// <remarks>
118+
/// <see cref="Fields"/> is empty when the generated query returned no rows - the runtime
119+
/// omits the schema body in that case.
120+
/// </remarks>
121+
public class NsqlSchema
122+
{
123+
/// <summary>
124+
/// The columns of the result, in order.
125+
/// </summary>
126+
[JsonPropertyName("fields")]
127+
public IReadOnlyList<NsqlField> Fields { get; set; } = new List<NsqlField>();
128+
}
129+
130+
/// <summary>
131+
/// The result of running a natural-language query via <see cref="SpiceClient.NsqlAsync"/>.
132+
/// </summary>
133+
public class NsqlResponse
134+
{
135+
/// <summary>
136+
/// The query the model generated. It is worth logging: a surprising result is usually a
137+
/// surprising query.
138+
/// </summary>
139+
[JsonPropertyName("sql")]
140+
public string SQL { get; set; } = string.Empty;
141+
142+
/// <summary>
143+
/// The number of rows returned.
144+
/// </summary>
145+
[JsonPropertyName("row_count")]
146+
public int RowCount { get; set; }
147+
148+
/// <summary>
149+
/// Describes the columns in <see cref="Data"/>.
150+
/// </summary>
151+
[JsonPropertyName("schema")]
152+
public NsqlSchema Schema { get; set; } = new();
153+
154+
/// <summary>
155+
/// The rows, each keyed by column name. Values are decoded from JSON, so they carry
156+
/// JSON's types rather than the Arrow types named in <see cref="Schema"/> - numbers
157+
/// arrive as <see cref="JsonElement"/> holding a number. Use
158+
/// <see cref="SpiceClient.NsqlGenerateSqlAsync"/> with <see cref="SpiceClient.Query"/>
159+
/// when Arrow-typed results matter.
160+
/// </summary>
161+
[JsonPropertyName("data")]
162+
public IReadOnlyList<IReadOnlyDictionary<string, JsonElement>> Data { get; set; }
163+
= new List<IReadOnlyDictionary<string, JsonElement>>();
164+
}

0 commit comments

Comments
 (0)