From 5caa0cb00e1648efb4828bb977495feaa20ad1d1 Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Fri, 28 Nov 2025 08:34:57 -0800 Subject: [PATCH] #3669 sp_BlitzCache AI First pass at adding AI advice into sp_BlitzCache. Working on #3669. --- sp_BlitzCache.sql | 500 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 455 insertions(+), 45 deletions(-) diff --git a/sp_BlitzCache.sql b/sp_BlitzCache.sql index 9e14ff76..4dcbc595 100644 --- a/sp_BlitzCache.sql +++ b/sp_BlitzCache.sql @@ -234,7 +234,10 @@ CREATE TABLE ##BlitzCacheProcs ( missing_indexes XML, SetOptions VARCHAR(MAX), Warnings VARCHAR(MAX), - Pattern NVARCHAR(20) + Pattern NVARCHAR(20), + ai_prompt NVARCHAR(MAX), + ai_advice NVARCHAR(MAX), + ai_raw_response NVARCHAR(MAX) ); GO @@ -269,9 +272,14 @@ ALTER PROCEDURE dbo.sp_BlitzCache @SkipAnalysis BIT = 0 , @BringThePain BIT = 0 , @MinimumExecutionCount INT = 0, - @Debug BIT = 0, + @Debug TINYINT = 0, /* 0 = no debugging info, 1 = normal debugging info, 2 = AI debugging info */ @CheckDateOverride DATETIMEOFFSET = NULL, @MinutesBack INT = NULL, + @AI TINYINT = 0, /* 1 = ask for advice, 2 = build prompt but don't actually call AI. Only works with a single query plan: automatically sets @ExpertMode = 1, @KeepCRLF = 1. */ + @AIModel VARCHAR(200) = NULL, /* Defaults to gpt-4.1-mini */ + @AIURL VARCHAR(200) = NULL, /* Defaults to https://api.openai.com/v1/chat/completions */ + @AICredential VARCHAR(200) = NULL, /* Defaults to 'https://api.openai.com' */ + @AIConfig NVARCHAR(500) = NULL, /* Table where AI config data is stored - can be in the format db.schema.table, schema.table, or just table. */ @Version VARCHAR(30) = NULL OUTPUT, @VersionDate DATETIME = NULL OUTPUT, @VersionCheckMode BIT = 0, @@ -301,20 +309,14 @@ IF @Help = 1 This script displays your most resource-intensive queries from the plan cache, and points to ways you can tune these queries to make them faster. - To learn more, visit http://FirstResponderKit.org where you can download new versions for free, watch training videos on how it works, get more info on the findings, contribute your own code, and more. Known limitations of this version: - - SQL Server 2008 and 2008R2 have a bug in trigger stats, so that output is - excluded by default. - @IgnoreQueryHashes and @OnlyQueryHashes require a CSV list of hashes with no spaces between the hash values. - Unknown limitations of this version: - - May or may not be vulnerable to the wick effect. - Changes - for the full list of improvements and fixes in this version, see: https://github.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/ @@ -465,13 +467,33 @@ IF @Help = 1 UNION ALL SELECT N'@Debug', N'BIT', - N'Setting this to 1 will print dynamic SQL and select data from all tables used.' + N'Setting this to 1 will print dynamic SQL and select data from all tables used. Setting to 2 will debug AI calls.' UNION ALL SELECT N'@MinutesBack', N'INT', N'How many minutes back to begin plan cache analysis. If you put in a positive number, we''ll flip it to negative.' + UNION ALL + SELECT N'@AI', + N'TINYINT', + N'1 = ask for advice. Only works with a single query plan for now. Automatically sets @ExpertMode = 1, @KeepCRLF = 1.' + + UNION ALL + SELECT N'@AIModel', + N'VARCHAR(200)', + N'Defaults to gpt-4.1-mini. Can accept other models, or if you have a dbo.AI_Services table, we will look up services there.' + + UNION ALL + SELECT N'@AIURL', + N'VARCHAR(200)', + N'Defaults to https://api.openai.com/v1/chat/completions. Can accept other URLs, or if you have a dbo.AI_Services table, we will look up services there.' + + UNION ALL + SELECT N'@AICredential', + N'VARCHAR(200)', + N'The database scoped credential that you configured. Defaults to https://api.openai.com. Must be a subset of the @AIURL parameter.' + UNION ALL SELECT N'@Version', N'VARCHAR(30)', @@ -723,7 +745,12 @@ IF @Help = 1 UNION ALL SELECT N'Warnings', N'VARCHAR(MAX)', - N'A list of individual warnings generated by this query.' ; + N'A list of individual warnings generated by this query.' + + UNION ALL + SELECT N'AI Advice', + N'NVARCHAR(MAX)', + N'If called with @AI parameters, the results from your AI provider.' ; @@ -761,7 +788,14 @@ IF @Help = 1 SELECT N'Unused Memory Grant Warning' AS [Configuration Parameter] , N'10' , N'Percent' , - N'Triggers an "Unused Memory Grant Warning" when a query uses >= X percent of its memory grant.'; + N'Triggers an "Unused Memory Grant Warning" when a query uses >= X percent of its memory grant.' + + UNION ALL + SELECT N'AIModel Default' AS [Configuration Parameter] , + N'1' , + N'URL' , + N'Default provider is https://api.openai.com/v1/chat/completions. Override goes in the value_varchar_1, override system prompt in value_varchar_2. We use the lowest value column first.' + RETURN; END; /* IF @Help = 1 */ @@ -794,7 +828,6 @@ BEGIN SET @HideSummary = 1; END; - /* Lets get @SortOrder set to lower case here for comparisons later */ SET @SortOrder = LOWER(@SortOrder); @@ -816,6 +849,129 @@ IF ( END; +IF OBJECT_ID ('tempdb..#configuration') IS NOT NULL + DROP TABLE #configuration; + +CREATE TABLE #configuration ( + parameter_name VARCHAR(100), + value DECIMAL(38,0) +); + +DECLARE @config_sql NVARCHAR(MAX) +IF @ConfigurationDatabaseName IS NOT NULL +BEGIN + RAISERROR(N'Reading values from Configuration Table', 0, 1) WITH NOWAIT; + SET @config_sql = N'INSERT INTO #configuration SELECT parameter_name, value FROM ' + + QUOTENAME(@ConfigurationDatabaseName) + + '.' + QUOTENAME(@ConfigurationSchemaName) + + '.' + QUOTENAME(@ConfigurationTableName) + + ' ; ' ; + EXEC(@config_sql); +END; + +CREATE TABLE #ai_configuration +(Id INT PRIMARY KEY CLUSTERED, + AI_Model NVARCHAR(100) INDEX AI_Model, + AI_URL NVARCHAR(500), + AI_Database_Scoped_Credential_Name NVARCHAR(500), + AI_System_Prompt_Override NVARCHAR(4000), + AI_Parameters NVARCHAR(4000), + Timeout_Seconds TINYINT, + DefaultModel BIT DEFAULT 0); + +DECLARE + @AIConfigDatabaseName NVARCHAR(128) = CASE WHEN @AIConfig IS NULL THEN NULL ELSE PARSENAME(@AIConfig, 3) END, + @AIConfigSchemaName NVARCHAR(258) = CASE WHEN @AIConfig IS NULL THEN NULL ELSE PARSENAME(@AIConfig, 2) END, + @AIConfigTableName NVARCHAR(258) = CASE WHEN @AIConfig IS NULL THEN NULL ELSE PARSENAME(@AIConfig, 1) END, + @AISystemPrompt NVARCHAR(4000), + @AIParameters NVARCHAR(4000), + @AITimeoutSeconds TINYINT, + @AIAdviceText NVARCHAR(MAX); + + +IF @AIConfig IS NOT NULL +BEGIN + RAISERROR(N'Reading values from AI Configuration Table', 0, 1) WITH NOWAIT; + SET @config_sql = N'INSERT INTO #ai_configuration SELECT Id, AI_Model, AI_URL, AI_Database_Scoped_Credential_Name, AI_System_Prompt_Override, AI_Parameters, Timeout_Seconds, DefaultModel FROM ' + + CASE WHEN @AIConfigDatabaseName IS NOT NULL THEN (QUOTENAME(@AIConfigDatabaseName) + N'.') ELSE N'' END + + CASE WHEN @AIConfigSchemaName IS NOT NULL THEN (QUOTENAME(@AIConfigSchemaName) + N'.') ELSE N'' END + + QUOTENAME(@AIConfigTableName) + N' WHERE DefaultModel = 1 OR AI_Model = @AIModel ; '; + EXEC sp_executesql @config_sql, N'@AIModel NVARCHAR(100)', @AIModel; +END; + + +IF @AI > 0 + BEGIN + RAISERROR(N'Setting up AI configuration defaults', 0, 1) WITH NOWAIT; + + SELECT @ExpertMode = 1, @KeepCRLF = 1; + + IF @AI = 1 AND NOT EXISTS(SELECT * FROM sys.all_objects WHERE name = 'sp_invoke_external_rest_endpoint') + BEGIN + /* If someone was ambitious and they wanted to code a drop-in replacement for that stored proc, + and use it in earlier versions of SQL Server, they could, and just use the same name and + parameters, and we'd use it. I'm not coding support for different proc names. */ + SET @AI = 2 + RAISERROR(N'@AI was set to 1, but sp_invoke_external_rest_endpoint does not exist here, so we can''t call AI services. Setting @AI to 2 instead to just generate prompts.', 0, 1) WITH NOWAIT; + END + + IF @AIModel IS NULL + SELECT TOP 1 @AIModel = AI_Model, @AIURL = AI_URL, + @AICredential = AI_Database_Scoped_Credential_Name, + @AISystemPrompt = AI_System_Prompt_Override, + @AIParameters = AI_Parameters, + @AITimeoutSeconds = COALESCE(Timeout_Seconds, 30) + FROM #ai_configuration + WHERE DefaultModel = 1 + ORDER BY Id; + ELSE + SELECT TOP 1 @AIURL = COALESCE(@AIURL, AI_URL), + @AICredential = COALESCE(@AICredential, AI_Database_Scoped_Credential_Name), + @AISystemPrompt = AI_System_Prompt_Override, + @AIParameters = AI_Parameters, + @AITimeoutSeconds = COALESCE(Timeout_Seconds, 30) + FROM #ai_configuration + WHERE AI_Model = @AIModel + ORDER BY Id; + + IF @AIURL IS NULL OR @AIURL NOT LIKE N'http%' + SET @AIURL = N'https://api.openai.com/v1/chat/completions'; + + IF @AICredential IS NULL OR @AICredential NOT LIKE 'http%' + SET @AICredential = N'https://api.openai.com'; + + IF @AISystemPrompt IS NULL OR @AISystemPrompt = N'' + 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. + + 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. + + 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.'; + + IF @Debug IN (1,2) OR (@AI = 1 AND (@AIModel IS NULL OR @AIURL IS NULL OR @AISystemPrompt IS NULL OR @AICredential IS NULL)) + BEGIN + PRINT N'@AIModel: '; + PRINT @AIModel; + PRINT N'@AIURL: '; + PRINT @AIURL; + PRINT N'@AICredential: '; + PRINT @AICredential; + PRINT N'@AIParameters: '; + PRINT @AIParameters; + PRINT N'@AITimeoutSeconds: '; + PRINT @AITimeoutSeconds; + PRINT N'@AISystemPrompt: '; + PRINT @AISystemPrompt; + END; + + IF @AI = 1 AND (@AIModel IS NULL OR @AIURL IS NULL OR @AISystemPrompt IS NULL OR @AICredential IS NULL) + BEGIN + RAISERROR('@AI is set to 1, but not all of the necessary configuration is included.',12,1); + RETURN; + END; + + END + + /* If they want to sort by query hash, populate the @OnlyQueryHashes list for them */ IF @SortOrder LIKE 'query hash%' BEGIN @@ -887,9 +1043,9 @@ BEGIN RETURN; END; -RAISERROR(N'Checking @MinutesBack validity.', 0, 1) WITH NOWAIT; IF @MinutesBack IS NOT NULL BEGIN + RAISERROR(N'Checking @MinutesBack validity.', 0, 1) WITH NOWAIT; IF @MinutesBack > 0 BEGIN RAISERROR(N'Setting @MinutesBack to a negative number', 0, 1) WITH NOWAIT; @@ -917,12 +1073,14 @@ DECLARE @DurationFilter_i INT, @user_perm_percent DECIMAL(10,2), @is_tokenstore_big BIT = 0, @sort NVARCHAR(MAX) = N'', - @sort_filter NVARCHAR(MAX) = N''; + @sort_filter NVARCHAR(MAX) = N'', + @AIPayload NVARCHAR(MAX), + @AIResponse NVARCHAR(MAX); IF @SortOrder = 'sp_BlitzIndex' BEGIN - RAISERROR(N'OUTSTANDING!', 0, 1) WITH NOWAIT; + RAISERROR(N'Called for sp_BlitzIndex', 0, 1) WITH NOWAIT; SET @SortOrder = 'reads'; SET @NoobSaibot = 1; @@ -1058,9 +1216,6 @@ IF OBJECT_ID('tempdb..#p') IS NOT NULL IF OBJECT_ID ('tempdb..#checkversion') IS NOT NULL DROP TABLE #checkversion; -IF OBJECT_ID ('tempdb..#configuration') IS NOT NULL - DROP TABLE #configuration; - IF OBJECT_ID ('tempdb..#stored_proc_info') IS NOT NULL DROP TABLE #stored_proc_info; @@ -1146,11 +1301,6 @@ CREATE TABLE #checkversion ( revision AS PARSENAME(CONVERT(VARCHAR(32), version), 1) ); -CREATE TABLE #configuration ( - parameter_name VARCHAR(100), - value DECIMAL(38,0) -); - CREATE TABLE #plan_creation ( percent_24 DECIMAL(5, 2), @@ -1732,17 +1882,6 @@ BEGIN END; END; -IF @ConfigurationDatabaseName IS NOT NULL -BEGIN - RAISERROR(N'Reading values from Configuration Database', 0, 1) WITH NOWAIT; - DECLARE @config_sql NVARCHAR(MAX) = N'INSERT INTO #configuration SELECT parameter_name, value FROM ' - + QUOTENAME(@ConfigurationDatabaseName) - + '.' + QUOTENAME(@ConfigurationSchemaName) - + '.' + QUOTENAME(@ConfigurationTableName) - + ' ; ' ; - EXEC(@config_sql); -END; - RAISERROR(N'Setting up variables', 0, 1) WITH NOWAIT; DECLARE @sql NVARCHAR(MAX) = N'', @insert_list NVARCHAR(MAX) = N'', @@ -4925,6 +5064,268 @@ AND SPID = @@SPID OPTION (RECOMPILE); + +/* Artificial Intelligence: Like Sony Aibo, But For Your Database */ +IF @AI >= 1 +BEGIN + RAISERROR('Building AI prompts for query plans', 0, 1) WITH NOWAIT; + + /* Update ai_prompt column with query metrics for rows that have query plans */ + UPDATE ##BlitzCacheProcs + SET ai_prompt = N'Here are the performance metrics we are seeing in production, as measured by the plan cache: + +Database: ' + ISNULL(DatabaseName, N'Unknown') + N' +Query Type: ' + ISNULL(QueryType, N'Unknown') + N' +Execution Count: ' + ISNULL(CAST(ExecutionCount AS NVARCHAR(30)), N'N/A') + N' +Executions Per Minute: ' + ISNULL(CAST(ExecutionsPerMinute AS NVARCHAR(30)), N'N/A') + N' + +CPU Metrics: +- Total CPU (ms): ' + ISNULL(CAST(TotalCPU AS NVARCHAR(30)), N'N/A') + N' +- Average CPU (ms): ' + ISNULL(CAST(AverageCPU AS NVARCHAR(30)), N'N/A') + N' +- Min Worker Time (ms): ' + ISNULL(CAST(min_worker_time AS NVARCHAR(30)), N'N/A') + N' +- Max Worker Time (ms): ' + ISNULL(CAST(max_worker_time AS NVARCHAR(30)), N'N/A') + N' + +Duration Metrics: +- Total Duration (ms): ' + ISNULL(CAST(TotalDuration AS NVARCHAR(30)), N'N/A') + N' +- Average Duration (ms): ' + ISNULL(CAST(AverageDuration AS NVARCHAR(30)), N'N/A') + N' +- Min Elapsed Time (ms): ' + ISNULL(CAST(min_elapsed_time AS NVARCHAR(30)), N'N/A') + N' +- Max Elapsed Time (ms): ' + ISNULL(CAST(max_elapsed_time AS NVARCHAR(30)), N'N/A') + N' + +I/O Metrics: +- Total Reads: ' + ISNULL(CAST(TotalReads AS NVARCHAR(30)), N'N/A') + N' +- Average Reads: ' + ISNULL(CAST(AverageReads AS NVARCHAR(30)), N'N/A') + N' +- Total Writes: ' + ISNULL(CAST(TotalWrites AS NVARCHAR(30)), N'N/A') + N' +- Average Writes: ' + ISNULL(CAST(AverageWrites AS NVARCHAR(30)), N'N/A') + N' + +Row Statistics: +- Total Returned Rows: ' + ISNULL(CAST(TotalReturnedRows AS NVARCHAR(30)), N'N/A') + N' +- Average Returned Rows: ' + ISNULL(CAST(AverageReturnedRows AS NVARCHAR(30)), N'N/A') + N' +- Min Returned Rows: ' + ISNULL(CAST(MinReturnedRows AS NVARCHAR(30)), N'N/A') + N' +- Max Returned Rows: ' + ISNULL(CAST(MaxReturnedRows AS NVARCHAR(30)), N'N/A') + N' +- Estimated Rows: ' + ISNULL(CAST(estimated_rows AS NVARCHAR(30)), N'N/A') + N' + +Memory Grant Info: +- Min Grant KB: ' + ISNULL(CAST(MinGrantKB AS NVARCHAR(30)), N'N/A') + N' +- Max Grant KB: ' + ISNULL(CAST(MaxGrantKB AS NVARCHAR(30)), N'N/A') + N' +- Min Used Grant KB: ' + ISNULL(CAST(MinUsedGrantKB AS NVARCHAR(30)), N'N/A') + N' +- Max Used Grant KB: ' + ISNULL(CAST(MaxUsedGrantKB AS NVARCHAR(30)), N'N/A') + N' +- Percent Memory Grant Used: ' + ISNULL(CAST(PercentMemoryGrantUsed AS NVARCHAR(30)), N'N/A') + N'% + +Spill Info: +- Min Spills: ' + ISNULL(CAST(MinSpills AS NVARCHAR(30)), N'N/A') + N' +- Max Spills: ' + ISNULL(CAST(MaxSpills AS NVARCHAR(30)), N'N/A') + N' +- Total Spills: ' + ISNULL(CAST(TotalSpills AS NVARCHAR(30)), N'N/A') + N' +- Avg Spills: ' + ISNULL(CAST(AvgSpills AS NVARCHAR(30)), N'N/A') + N' + +Plan Info: +- Query Plan Cost: ' + ISNULL(CAST(QueryPlanCost AS NVARCHAR(30)), N'N/A') + N' +- Plan Creation Time: ' + ISNULL(CONVERT(NVARCHAR(30), PlanCreationTime, 120), N'N/A') + N' +- Plan Age (hours): ' + ISNULL(CAST(PlanCreationTimeHours AS NVARCHAR(30)), N'N/A') + N' +- Last Execution Time: ' + ISNULL(CONVERT(NVARCHAR(30), LastExecutionTime, 120), N'N/A') + N' +- Number of Plans: ' + ISNULL(CAST(NumberOfPlans AS NVARCHAR(30)), N'N/A') + N' +- Number of Distinct Plans: ' + ISNULL(CAST(NumberOfDistinctPlans AS NVARCHAR(30)), N'N/A') + N' +- Is Parallel: ' + CASE WHEN is_parallel = 1 THEN N'Yes' ELSE N'No' END + N' +- Is Trivial Plan: ' + CASE WHEN is_trivial = 1 THEN N'Yes' ELSE N'No' END + N' + +Here are the warnings that popular query analysis tool sp_BlitzCache detected and suggested that we focus on - although there may be more issues, too: ' + ISNULL(Warnings, N'None') + N' + +Query Text (which is cut off for long queries): +' + ISNULL(LEFT(QueryText, 4000), N'N/A') + N' + +XML Execution Plan: +' + ISNULL(CAST(QueryPlan AS NVARCHAR(MAX)), N'N/A') + N' + +Thank you.' + WHERE SPID = @@SPID + AND QueryPlan IS NOT NULL + OPTION (RECOMPILE); + + IF @Debug = 2 + SELECT 'Before Calling AI' AS ai_stage, SqlHandle, QueryHash, PlanHandle, QueryPlan, ai_prompt, ai_advice, ai_raw_response + FROM ##BlitzCacheProcs + WHERE SPID = @@SPID; + + IF @AI = 1 + BEGIN + RAISERROR('Calling AI endpoint for query plan analysis - starting loop', 0, 1) WITH NOWAIT; + + DECLARE @CurrentSqlHandle VARBINARY(64); + DECLARE @CurrentQueryHash BINARY(8); + DECLARE @CurrentPlanHandle VARBINARY(64); + DECLARE @CurrentAIPrompt NVARCHAR(MAX); + DECLARE @CurrentQueryClipped NVARCHAR(200); + DECLARE @AIResponseJSON NVARCHAR(MAX); + DECLARE @AIReturnValue INT; + DECLARE @AIErrorMessage NVARCHAR(4000); + + DECLARE ai_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT SqlHandle, QueryHash, PlanHandle, ai_prompt, COALESCE(QueryType, N'') + N' - ' + LEFT(QueryText, 100) + FROM ##BlitzCacheProcs + WHERE SPID = @@SPID + AND QueryPlan IS NOT NULL + AND ai_prompt IS NOT NULL; + + OPEN ai_cursor; + + FETCH NEXT FROM ai_cursor INTO @CurrentSqlHandle, @CurrentQueryHash, @CurrentPlanHandle, @CurrentAIPrompt, @CurrentQueryClipped; + + WHILE @@FETCH_STATUS = 0 + BEGIN + BEGIN TRY + SET @AIResponseJSON = NULL; + SET @AIResponse = NULL; + + /* Build the JSON payload for the API call. Escape special characters in the prompt for JSON: */ + SET @CurrentAIPrompt = REPLACE(@CurrentAIPrompt, '\', '\\'); + SET @CurrentAIPrompt = REPLACE(@CurrentAIPrompt, '"', '\"'); + SET @CurrentAIPrompt = REPLACE(@CurrentAIPrompt, CHAR(13), '\r'); + SET @CurrentAIPrompt = REPLACE(@CurrentAIPrompt, CHAR(10), '\n'); + SET @CurrentAIPrompt = REPLACE(@CurrentAIPrompt, CHAR(9), '\t'); + + /* Build payload based on API type (OpenAI-compatible format works for most providers) */ + SET @AIPayload = N'{ + "model": "' + @AIModel + N'", + "messages": [ + { + "role": "system", + "content": "' + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(@AISystemPrompt, '\', '\\'), '"', '\"'), CHAR(13), '\r'), CHAR(10), '\n'), CHAR(9), '\t') + N'" + }, + { + "role": "user", + "content": "' + @CurrentAIPrompt + N'" + } + ] + }'; + + IF @Debug = 2 + BEGIN + RAISERROR('AI Payload (first 4000 chars):', 0, 1) WITH NOWAIT; + PRINT LEFT(@AIPayload, 4000); + END; + + RAISERROR('Calling AI endpoint for query plan analysis on query: ', 0, 1) WITH NOWAIT; + RAISERROR(@CurrentQueryClipped, 0, 1) WITH NOWAIT; + + EXEC @AIReturnValue = sp_invoke_external_rest_endpoint + @url = @AIURL, + @method = 'POST', + @payload = @AIPayload, + @headers = N'{"Content-Type":"application/json"}', + @credential = @AICredential, + @timeout = @AITimeoutSeconds, + @response = @AIResponseJSON OUTPUT; + + + IF @Debug = 2 + BEGIN + PRINT N'API Response (first 4000 chars): ' + @nl + LEFT(ISNULL(@AIResponseJSON, N'NULL'), 4000); + END; + + /* Parse the response to extract the AI's advice + OpenAI format: {"choices":[{"message":{"content":"..."}}]} */ + IF @AIResponseJSON IS NOT NULL + BEGIN + SET @AIAdviceText = (SELECT c.Content + FROM OPENJSON(@AIResponseJSON, '$.result.choices') + WITH ( + Content nvarchar(max) '$.message.content' + ) AS c); + + IF @Debug = 2 + BEGIN + SELECT '@AIResponseJSON parsed with OPENJSON:' AS Label, c.Content + FROM OPENJSON(@AIResponseJSON, '$.response.result.choices') + WITH ( + Content nvarchar(max) '$.message.content' + ) AS c; + END; + + /* If we couldn't parse it, check for error codes */ + IF @AIAdviceText IS NULL + BEGIN + DECLARE @ErrorMessage NVARCHAR(MAX); + SELECT @ErrorMessage = JSON_VALUE(@AIResponseJSON, '$.result.error.message'); + + IF @ErrorMessage IS NULL + SELECT @ErrorMessage = JSON_VALUE(@AIResponseJSON, '$.error.message'); + + IF @ErrorMessage IS NOT NULL + SET @AIAdviceText = N'API Error: ' + @ErrorMessage; + ELSE + SET @AIAdviceText = N'Unable to parse API response. Raw response stored for debugging.'; + END; + END + ELSE + BEGIN + SET @AIAdviceText = N'No response received from AI service.'; + END; + + /* Store the response in the the ai_advice column */ + UPDATE ##BlitzCacheProcs + SET ai_advice = @AIAdviceText, ai_raw_response = @AIResponseJSON + WHERE SPID = @@SPID + AND ((@CurrentSqlHandle IS NOT NULL AND SqlHandle = @CurrentSqlHandle) + OR (@CurrentSqlHandle IS NULL AND SqlHandle IS NULL)) + AND ((@CurrentQueryHash IS NOT NULL AND QueryHash = @CurrentQueryHash) + OR (@CurrentQueryHash IS NULL AND QueryHash IS NULL)) + AND ((@CurrentPlanHandle IS NOT NULL AND PlanHandle = @CurrentPlanHandle) + OR (@CurrentPlanHandle IS NULL AND PlanHandle IS NULL)) + OPTION (RECOMPILE); + + END TRY + BEGIN CATCH + SET @AIErrorMessage = N'Error calling AI service: ' + ERROR_MESSAGE(); + + IF @Debug = 1 + BEGIN + PRINT @AIErrorMessage; + END; + + -- Store the error message in ai_advice so the user knows what happened + UPDATE ##BlitzCacheProcs + SET ai_advice = @AIErrorMessage, ai_raw_response = @AIResponseJSON + WHERE SPID = @@SPID + AND ((@CurrentSqlHandle IS NOT NULL AND SqlHandle = @CurrentSqlHandle) + OR (@CurrentSqlHandle IS NULL AND SqlHandle IS NULL)) + AND ((@CurrentQueryHash IS NOT NULL AND QueryHash = @CurrentQueryHash) + OR (@CurrentQueryHash IS NULL AND QueryHash IS NULL)) + AND ((@CurrentPlanHandle IS NOT NULL AND PlanHandle = @CurrentPlanHandle) + OR (@CurrentPlanHandle IS NULL AND PlanHandle IS NULL)) + OPTION (RECOMPILE); + END CATCH; + + FETCH NEXT FROM ai_cursor INTO @CurrentSqlHandle, @CurrentQueryHash, @CurrentPlanHandle, @CurrentAIPrompt, @CurrentQueryClipped; + END; + + CLOSE ai_cursor; + DEALLOCATE ai_cursor; + + RAISERROR('AI analysis complete', 0, 1) WITH NOWAIT; + + IF @Debug = 2 + SELECT 'After Calling AI' AS ai_stage, SqlHandle, QueryHash, PlanHandle, QueryPlan, ai_prompt, ai_advice, ai_raw_response + FROM ##BlitzCacheProcs + WHERE SPID = @@SPID; + + RAISERROR('AI analysis complete', 0, 1) WITH NOWAIT; + END; + ELSE + BEGIN + /* @AI = 2: Just update ai_advice to indicate prompt-only mode */ + UPDATE ##BlitzCacheProcs + SET ai_advice = N'AI prompt generated but not sent (running with @AI = 2). Review the ai_prompt column for the prompt that would be sent.' + WHERE SPID = @@SPID + AND QueryPlan IS NOT NULL + OPTION (RECOMPILE); + END; +END; + + + + + + + Results: IF @ExportToExcel = 1 BEGIN @@ -5111,7 +5512,11 @@ BEGIN QueryPlan AS [Query Plan], missing_indexes AS [Missing Indexes], implicit_conversion_info AS [Implicit Conversion Info], - cached_execution_parameters AS [Cached Execution Parameters], ' + @nl; + cached_execution_parameters AS [Cached Execution Parameters], + [AI Prompt] = ( + SELECT (@AISystemPrompt + NCHAR(13) + NCHAR(10) + NCHAR(13) + NCHAR(10) + ai_prompt) AS [text()] FOR XML PATH(''ai_prompt''), TYPE), + [AI Advice] = CASE WHEN ai_advice IS NULL THEN NULL ELSE ( + SELECT ai_advice AS [text()] FOR XML PATH(''ai_advice''), TYPE) END, ' + @nl; IF @ExpertMode = 2 /* Opserver */ BEGIN @@ -5241,6 +5646,8 @@ BEGIN StatementStartOffset, StatementEndOffset, PlanGenerationNum, + [AI Raw Response] = CASE WHEN ai_raw_response IS NULL THEN NULL ELSE ( + SELECT ai_raw_response AS [text()] FOR XML PATH(''ai_raw_response''), TYPE) END, [Remove Plan Handle From Cache], [Remove SQL Handle From Cache]'; END; @@ -5296,7 +5703,7 @@ IF @Debug = 1 END; IF(@OutputType <> 'NONE') BEGIN - EXEC sp_executesql @sql, N'@Top INT, @spid INT, @MinimumExecutionCount INT, @min_back INT', @Top, @@SPID, @MinimumExecutionCount, @MinutesBack; + EXEC sp_executesql @sql, N'@Top INT, @spid INT, @MinimumExecutionCount INT, @min_back INT, @AISystemPrompt NVARCHAR(4000)', @Top, @@SPID, @MinimumExecutionCount, @MinutesBack, @AISystemPrompt; END; /* @@ -6447,17 +6854,20 @@ IF @Debug = 1 FROM ##BlitzCacheProcs OPTION ( RECOMPILE ); - SELECT '#statements' AS table_name, * - FROM #statements AS s - OPTION (RECOMPILE); + IF @SkipAnalysis = 0 + BEGIN + SELECT '#statements' AS table_name, * + FROM #statements AS s + OPTION (RECOMPILE); - SELECT '#query_plan' AS table_name, * - FROM #query_plan AS qp - OPTION (RECOMPILE); + SELECT '#query_plan' AS table_name, * + FROM #query_plan AS qp + OPTION (RECOMPILE); - SELECT '#relop' AS table_name, * - FROM #relop AS r - OPTION (RECOMPILE); + SELECT '#relop' AS table_name, * + FROM #relop AS r + OPTION (RECOMPILE); + END SELECT '#only_query_hashes' AS table_name, * FROM #only_query_hashes