Skip to content

Commit d6b989f

Browse files
BrentOzarclaude
andcommitted
#3670 sp_BlitzIndex: implement @ai prompt assembly and API call
Add the core AI feature for sp_BlitzIndex single-table mode: - Declare @AIPayload, @AIResponseJSON, @AIReturnValue, @CurrentAIPrompt - Make default system prompt index-focused when @TableName is specified - Build AI prompt with 4 data sections using FOR XML PATH concatenation: 1. Existing indexes (from #IndexSanity + #IndexSanitySize) 2. Missing index suggestions (from #MissingIndexes) 3. Column data types (from #IndexColumns) 4. Foreign keys (from #ForeignKeys) - Constitution lookup via database extended property - API call pattern matching sp_BlitzCache (OpenAI + Gemini parsing) - Result sets: AI Prompt (always), AI Advice/Payload/Raw Response (@ai=1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf8d932 commit d6b989f

1 file changed

Lines changed: 229 additions & 2 deletions

File tree

sp_BlitzIndex.sql

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ DECLARE
155155
@AIPayloadTemplate NVARCHAR(MAX),
156156
@AITimeoutSeconds TINYINT,
157157
@AIAdviceText NVARCHAR(MAX),
158-
@AIContext INT;
158+
@AIContext INT,
159+
@AIPayload NVARCHAR(MAX),
160+
@AIResponseJSON NVARCHAR(MAX),
161+
@AIReturnValue INT,
162+
@CurrentAIPrompt NVARCHAR(MAX);
159163

160164
/* If user was lazy and just used @ObjectName with a fully qualified table name, then lets parse out the various parts */
161165
SET @DatabaseName = COALESCE(@DatabaseName, PARSENAME(@ObjectName, 3)) /* 3 = Database name */
@@ -1110,11 +1114,20 @@ IF @AI > 0
11101114
SET @AITimeoutSeconds = 230;
11111115

11121116
IF @AISystemPrompt IS NULL OR @AISystemPrompt = N''
1113-
SET @AISystemPrompt = N'You are a very senior database developer working with Microsoft SQL Server and Azure SQL DB. You focus on real-world, actionable advice that will make a big difference, quickly. You value everyone''s time, and while you are friendly and courteous, you do not waste time with pleasantries or emoji because you work in a fast-paced corporate environment.
1117+
BEGIN
1118+
IF @TableName IS NOT NULL
1119+
SET @AISystemPrompt = N'You are a very senior database developer working with Microsoft SQL Server and Azure SQL DB. You focus on real-world, actionable advice that will make a big difference, quickly. You value everyone''s time, and while you are friendly and courteous, you do not waste time with pleasantries or emoji because you work in a fast-paced corporate environment.
1120+
1121+
You have been given the existing indexes, missing index suggestions from SQL Server, column data types, and foreign keys for a table. Your job is to recommend index changes: which indexes to add, which to remove as redundant or harmful, and which to modify. Focus on practical changes that will improve the most common query patterns shown by the usage statistics. Include CREATE INDEX and DROP INDEX scripts where appropriate.
1122+
1123+
Do not offer followup options: the customer can only contact you once, so include all necessary information, tasks, and scripts in your initial reply. Render your output in Markdown, as it will be shown in plain text to the customer.';
1124+
ELSE
1125+
SET @AISystemPrompt = N'You are a very senior database developer working with Microsoft SQL Server and Azure SQL DB. You focus on real-world, actionable advice that will make a big difference, quickly. You value everyone''s time, and while you are friendly and courteous, you do not waste time with pleasantries or emoji because you work in a fast-paced corporate environment.
11141126
11151127
You have a query that isn''t performing to end user expectations. You have been tasked with making serious improvements to it, quickly. You are not allowed to change server-level settings or make frivolous suggestions like updating statistics. Instead, you need to focus on query changes or index changes.
11161128
11171129
Do not offer followup options: the customer can only contact you once, so include all necessary information, tasks, and scripts in your initial reply. Render your output in Markdown, as it will be shown in plain text to the customer.';
1130+
END;
11181131

11191132
IF @AIModel LIKE 'gemini%' AND @AIPayloadTemplate IS NULL
11201133
SET @AIPayloadTemplate = N'{
@@ -3845,6 +3858,220 @@ BEGIN
38453858
IF @ShowColumnstoreOnly = 1
38463859
RETURN;
38473860

3861+
/* @AskAI: Index analysis via AI provider */
3862+
IF @AI >= 1 AND @TableName IS NOT NULL
3863+
BEGIN
3864+
RAISERROR(N'Building AI prompt for index analysis', 0, 1) WITH NOWAIT;
3865+
3866+
/* Constitution lookup */
3867+
DECLARE @ai_constitution NVARCHAR(MAX) = NULL;
3868+
BEGIN TRY
3869+
SET @StringToExecute = N'SELECT @c = CAST(value AS NVARCHAR(MAX))
3870+
FROM ' + QUOTENAME(@DatabaseName) + N'.sys.extended_properties
3871+
WHERE class = 0 AND major_id = 0 AND minor_id = 0
3872+
AND name = N''CONSTITUTION.md'';';
3873+
EXEC sp_executesql @StringToExecute, N'@c NVARCHAR(MAX) OUTPUT', @c = @ai_constitution OUTPUT;
3874+
END TRY
3875+
BEGIN CATCH
3876+
/* If we can't read it (permissions, offline, etc), just skip. */
3877+
END CATCH;
3878+
3879+
/* Build the prompt header */
3880+
SET @CurrentAIPrompt = N'I need help analyzing the indexes on the table '
3881+
+ QUOTENAME(@DatabaseName) + N'.' + QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName)
3882+
+ N'.' + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10);
3883+
3884+
/* Prepend constitution if found */
3885+
IF @ai_constitution IS NOT NULL AND LEN(@ai_constitution) > 0
3886+
SET @CurrentAIPrompt = N'---' + CHAR(13) + CHAR(10)
3887+
+ N'This database has an extended property named CONSTITUTION.md that provides additional guidance for AI analysis. Here is the content of that property:' + CHAR(13) + CHAR(10)
3888+
+ N'---' + CHAR(13) + CHAR(10) + @ai_constitution + CHAR(13) + CHAR(10)
3889+
+ N'---' + CHAR(13) + CHAR(10) + @CurrentAIPrompt;
3890+
3891+
/* Section 1: Existing Indexes
3892+
Use FOR XML PATH to reliably concatenate all rows into a single string. */
3893+
SET @CurrentAIPrompt = @CurrentAIPrompt + N'EXISTING INDEXES:' + CHAR(13) + CHAR(10);
3894+
3895+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3896+
SELECT
3897+
N'Index: ' + ISNULL(s.index_name, N'[HEAP]') + N' (IndexID: ' + CAST(s.index_id AS NVARCHAR(10)) + N')' + CHAR(13) + CHAR(10)
3898+
+ N' Type: ' + CASE s.index_id WHEN 0 THEN N'HEAP' WHEN 1 THEN N'CLUSTERED' ELSE N'NONCLUSTERED' END
3899+
+ CASE WHEN s.is_NC_columnstore = 1 THEN N' COLUMNSTORE' WHEN s.is_CX_columnstore = 1 THEN N' CLUSTERED COLUMNSTORE' ELSE N'' END + CHAR(13) + CHAR(10)
3900+
+ N' Key Columns: ' + ISNULL(s.key_column_names_with_sort_order, N'N/A') + CHAR(13) + CHAR(10)
3901+
+ N' Include Columns: ' + ISNULL(s.include_column_names, N'None') + CHAR(13) + CHAR(10)
3902+
+ CASE WHEN s.filter_definition <> N'' THEN N' Filter: ' + s.filter_definition + CHAR(13) + CHAR(10) ELSE N'' END
3903+
+ N' Is Primary Key: ' + CASE WHEN s.is_primary_key = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10)
3904+
+ N' Is Unique: ' + CASE WHEN s.is_unique = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10)
3905+
+ N' Is Disabled: ' + CASE WHEN s.is_disabled = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10)
3906+
+ N' Usage - Seeks: ' + CAST(s.user_seeks AS NVARCHAR(30)) + N', Scans: ' + CAST(s.user_scans AS NVARCHAR(30))
3907+
+ N', Lookups: ' + CAST(s.user_lookups AS NVARCHAR(30)) + N', Writes: ' + ISNULL(CAST(s.user_updates AS NVARCHAR(30)), N'0') + CHAR(13) + CHAR(10)
3908+
+ N' Rows: ' + ISNULL(CAST(sz.total_rows AS NVARCHAR(30)), N'N/A') + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10)
3909+
FROM #IndexSanity s
3910+
LEFT JOIN #IndexSanitySize sz ON s.index_sanity_id = sz.index_sanity_id
3911+
WHERE s.[object_id] = @ObjectID
3912+
ORDER BY s.index_id
3913+
FOR XML PATH(''), TYPE
3914+
).value('.', 'NVARCHAR(MAX)'), N'No indexes on this table (heap with no nonclustered indexes).' + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10));
3915+
3916+
/* Section 2: Missing Index Suggestions */
3917+
SET @CurrentAIPrompt = @CurrentAIPrompt + N'MISSING INDEX SUGGESTIONS FROM SQL SERVER:' + CHAR(13) + CHAR(10);
3918+
3919+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3920+
SELECT
3921+
N'Equality Columns: ' + ISNULL(equality_columns_with_data_type, N'None') + CHAR(13) + CHAR(10)
3922+
+ N'Inequality Columns: ' + ISNULL(inequality_columns_with_data_type, N'None') + CHAR(13) + CHAR(10)
3923+
+ N'Include Columns: ' + ISNULL(included_columns_with_data_type, N'None') + CHAR(13) + CHAR(10)
3924+
+ N'Benefit Number: ' + CAST(magic_benefit_number AS NVARCHAR(30)) + CHAR(13) + CHAR(10)
3925+
+ N'User Seeks: ' + CAST(user_seeks AS NVARCHAR(30)) + N', User Scans: ' + CAST(user_scans AS NVARCHAR(30)) + CHAR(13) + CHAR(10)
3926+
+ N'Avg User Impact: ' + CAST(avg_user_impact AS NVARCHAR(30)) + N'%' + CHAR(13) + CHAR(10)
3927+
+ N'Create TSQL: ' + create_tsql + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10)
3928+
FROM #MissingIndexes
3929+
WHERE [object_id] = @ObjectID
3930+
ORDER BY magic_benefit_number DESC
3931+
FOR XML PATH(''), TYPE
3932+
).value('.', 'NVARCHAR(MAX)'), N'No missing index suggestions from SQL Server.' + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10));
3933+
3934+
/* Section 3: Column Data Types */
3935+
SET @CurrentAIPrompt = @CurrentAIPrompt + N'COLUMN DATA TYPES:' + CHAR(13) + CHAR(10);
3936+
3937+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3938+
SELECT
3939+
N'Column: ' + column_name
3940+
+ N', Type: ' + system_type_name
3941+
+ CASE WHEN max_length = -1 THEN N'(max)'
3942+
WHEN system_type_name IN (N'char', N'varchar', N'binary', N'varbinary') THEN N'(' + CAST(max_length AS NVARCHAR(20)) + N')'
3943+
WHEN system_type_name IN (N'nchar', N'nvarchar') THEN N'(' + CAST(max_length / 2 AS NVARCHAR(20)) + N')'
3944+
WHEN system_type_name IN (N'decimal', N'numeric') THEN N'(' + CAST([precision] AS NVARCHAR(20)) + N',' + CAST([scale] AS NVARCHAR(20)) + N')'
3945+
ELSE N'' END
3946+
+ N', Nullable: ' + CASE WHEN is_nullable = 1 THEN N'Yes' ELSE N'No' END
3947+
+ N', Identity: ' + CASE WHEN is_identity = 1 THEN N'Yes' ELSE N'No' END
3948+
+ CHAR(13) + CHAR(10)
3949+
FROM #IndexColumns
3950+
WHERE [object_id] = @ObjectID
3951+
AND index_id IN (0, 1)
3952+
ORDER BY column_name
3953+
FOR XML PATH(''), TYPE
3954+
).value('.', 'NVARCHAR(MAX)'), N'');
3955+
3956+
/* Section 4: Foreign Keys */
3957+
SET @CurrentAIPrompt = @CurrentAIPrompt + CHAR(13) + CHAR(10) + N'FOREIGN KEYS:' + CHAR(13) + CHAR(10);
3958+
3959+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3960+
SELECT
3961+
N'FK: ' + foreign_key_name + CHAR(13) + CHAR(10)
3962+
+ N' Parent: ' + parent_object_name + N' (' + parent_fk_columns + N')' + CHAR(13) + CHAR(10)
3963+
+ N' References: ' + referenced_object_name + N' (' + referenced_fk_columns + N')' + CHAR(13) + CHAR(10)
3964+
+ N' Disabled: ' + CASE WHEN is_disabled = 1 THEN N'Yes' ELSE N'No' END
3965+
+ N', Not Trusted: ' + CASE WHEN is_not_trusted = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10)
3966+
FROM #ForeignKeys
3967+
ORDER BY foreign_key_name
3968+
FOR XML PATH(''), TYPE
3969+
).value('.', 'NVARCHAR(MAX)'), N'No foreign keys on this table.' + CHAR(13) + CHAR(10));
3970+
3971+
/* Closing instruction */
3972+
SET @CurrentAIPrompt = @CurrentAIPrompt + CHAR(13) + CHAR(10)
3973+
+ N'Based on the above data, please provide index recommendations for this table. Consider which indexes are redundant, which missing indexes should be created, and whether the current indexing strategy is appropriate for the workload pattern shown by the usage statistics.';
3974+
3975+
/* @AI = 1: Call the AI provider */
3976+
IF @AI = 1
3977+
BEGIN
3978+
BEGIN TRY
3979+
SET @AIResponseJSON = NULL;
3980+
3981+
/* Build payload using the template */
3982+
SET @AIPayload = REPLACE(@AIPayloadTemplate, N'@AIModel', @AIModel);
3983+
SET @AIPayload = REPLACE(@AIPayload, N'@AISystemPrompt', REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(@AISystemPrompt, '\', '\\'), '"', '\"'), CHAR(13), '\r'), CHAR(10), '\n'), CHAR(9), '\t'));
3984+
SET @AIPayload = REPLACE(@AIPayload, N'@CurrentAIPrompt', REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(@CurrentAIPrompt, '\', '\\'), '"', '\"'), CHAR(13), '\r'), CHAR(10), '\n'), CHAR(9), '\t'));
3985+
3986+
IF @Debug = 2
3987+
SELECT @AIPayload AS AIPayload, LEN(@AIPayload) AS AIPayload_Length, DATALENGTH(@AIPayload) AS AIPayload_DataLength;
3988+
3989+
RAISERROR('Calling AI endpoint for index analysis', 0, 1) WITH NOWAIT;
3990+
3991+
EXEC @AIReturnValue = sp_invoke_external_rest_endpoint
3992+
@url = @AIURL,
3993+
@method = 'POST',
3994+
@payload = @AIPayload,
3995+
@headers = N'{"Content-Type":"application/json"}',
3996+
@credential = @AICredential,
3997+
@timeout = @AITimeoutSeconds,
3998+
@response = @AIResponseJSON OUTPUT;
3999+
4000+
IF @Debug = 2
4001+
PRINT N'API Response (first 4000 chars): ' + CHAR(13) + CHAR(10) + LEFT(ISNULL(@AIResponseJSON, N'NULL'), 4000);
4002+
4003+
/* Parse the response to extract the AI's advice */
4004+
IF @AIResponseJSON IS NOT NULL
4005+
BEGIN
4006+
/* Try OpenAI ChatGPT chat completion by default: */
4007+
SET @AIAdviceText = (SELECT c.Content
4008+
FROM OPENJSON(@AIResponseJSON, '$.result.choices')
4009+
WITH (
4010+
Content NVARCHAR(MAX) '$.message.content'
4011+
) AS c);
4012+
4013+
/* No data? How about Google Gemini: */
4014+
IF @AIAdviceText IS NULL
4015+
SET @AIAdviceText = (SELECT TOP 1 p.[text]
4016+
FROM OPENJSON(@AIResponseJSON, '$.result.candidates') AS cand
4017+
CROSS APPLY OPENJSON(cand.value, '$.content.parts')
4018+
WITH ([text] NVARCHAR(MAX) '$.text') AS p);
4019+
4020+
/* If we still couldn't parse it, check for error codes */
4021+
IF @AIAdviceText IS NULL
4022+
BEGIN
4023+
DECLARE @AIErrorMessage NVARCHAR(MAX);
4024+
SELECT @AIErrorMessage = JSON_VALUE(@AIResponseJSON, '$.result.error.message');
4025+
4026+
IF @AIErrorMessage IS NULL
4027+
SELECT @AIErrorMessage = JSON_VALUE(@AIResponseJSON, '$.error.message');
4028+
4029+
IF @AIErrorMessage IS NOT NULL
4030+
SET @AIAdviceText = N'API Error: ' + @AIErrorMessage;
4031+
ELSE
4032+
SET @AIAdviceText = N'Unable to parse API response. Raw response stored for debugging.';
4033+
END;
4034+
END
4035+
ELSE
4036+
BEGIN
4037+
SET @AIAdviceText = N'No response received from AI service.';
4038+
END;
4039+
4040+
END TRY
4041+
BEGIN CATCH
4042+
SET @AIAdviceText = N'Error calling AI service: ' + ERROR_MESSAGE();
4043+
4044+
IF @Debug = 1
4045+
PRINT @AIAdviceText;
4046+
END CATCH;
4047+
END
4048+
ELSE
4049+
BEGIN
4050+
/* @AI = 2: Just build the prompt, don't call AI */
4051+
SET @AIAdviceText = N'AI prompt generated but not sent (running with @AI = 2). Review the AI Prompt result set.';
4052+
END;
4053+
4054+
RAISERROR(N'Returning AI results', 0, 1) WITH NOWAIT;
4055+
4056+
/* Return the AI prompt (always when @AI >= 1) */
4057+
SELECT [AI Prompt] = (
4058+
SELECT (@AISystemPrompt + NCHAR(13) + NCHAR(10) + NCHAR(13) + NCHAR(10) + @CurrentAIPrompt)
4059+
AS [text()] FOR XML PATH('ai_prompt'), TYPE);
4060+
4061+
/* Return advice, payload, and raw response when @AI = 1 */
4062+
IF @AI = 1
4063+
BEGIN
4064+
SELECT
4065+
[AI Advice] = CASE WHEN @AIAdviceText IS NULL THEN NULL ELSE (
4066+
SELECT @AIAdviceText AS [text()] FOR XML PATH('ai_advice'), TYPE) END,
4067+
[AI Payload] = CASE WHEN @AIPayload IS NULL THEN NULL ELSE (
4068+
SELECT @AIPayload AS [text()] FOR XML PATH('ai_payload'), TYPE) END,
4069+
[AI Raw Response] = CASE WHEN @AIResponseJSON IS NULL THEN NULL ELSE (
4070+
SELECT @AIResponseJSON AS [text()] FOR XML PATH('ai_raw_response'), TYPE) END;
4071+
END;
4072+
4073+
END; /* IF @AI >= 1 AND @TableName IS NOT NULL */
4074+
38484075
END; /* IF @TableName IS NOT NULL */
38494076

38504077

0 commit comments

Comments
 (0)