public/Invoke-DbaiQuery.ps1

function Invoke-DbaiQuery {
    <#
    .SYNOPSIS
    Executes a natural language query on a SQL Server database.
 
    .DESCRIPTION
    The Invoke-DbaiQuery function allows you to execute a natural language query on a specified SQL Server database. It utilizes an AI assistant to generate the corresponding SQL query and returns the result.
 
    .PARAMETER Message
    The natural language query to execute on the database.
 
    .PARAMETER SqlInstance
    The SQL Server instance hosting the database. Default is "localhost".
 
    .PARAMETER SqlCredential
    The SQL Server credential to use for authentication.
 
    .PARAMETER Database
    The name of the database to query. Default is "Northwind".
 
    .PARAMETER AssistantName
    The name of the AI assistant to use for query generation.
 
    .PARAMETER As
    The output format of the result. Supported values are 'PSObject' and 'String'. Default is 'String'.
 
    .PARAMETER SkipSafetyCheck
    Allows execution of potentially SkipSafetyCheck SQL queries.
 
    .EXAMPLE
    PS C:\> Invoke-DbaiQuery -Message "Get the top 10 customers by total sales amount" -Database AdventureWorks2019
 
    This example executes a natural language query on the AdventureWorks2019 database to retrieve the top 10 customers by total sales amount.
 
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromRemainingArguments, Position = 0)]
        [string[]]$Message,
        [string]$SqlInstance,
        [pscredential]$SqlCredential,
        [string]$Database,
        [string]$AssistantName,
        [ValidateSet("PSObject", "String")]
        [string]$As = "String",
        [switch]$SkipSafetyCheck
    )
    begin {
        Write-Verbose "Starting Invoke-DbaiQuery function"
        $PSDefaultParameterValues['Write-Progress:Activity'] = "Getting answer"

        Write-Verbose "Initializing SQL Server instance and credentials"
        $servername = $SqlInstance
        if (-not $SqlCredential) {
            Write-Verbose "No SQL credential provided, using current environment username"
            $username = $env:USERNAME
        } else {
            $username = $SqlCredential.UserName
        }
        if ($SqlInstance -match '\\') {
            Write-Verbose "Replacing backslashes in SQL instance name"
            $servername = $servername -replace '\\', '-'
        }

        if (-not $SqlInstance) {
            Write-Verbose "No SQL instance specified, defaulting to localhost"
            $SqlInstance = "localhost"
        }
        if (-not $Database) {
            Write-Verbose "No database specified, defaulting to Northwind"
            $Database = "Northwind"
        }

        Write-Verbose "Setting default parameter values for SQL instance and credentials"
        if (-not $PSDefaultParameterValues["*:Sqlinstance"]) {
            $PSDefaultParameterValues["*:Sqlinstance"] = $SqlInstance
        }
        if (-not $PSDefaultParameterValues["*:SqlCredential"]) {
            $PSDefaultParameterValues["*:SqlCredential"] = $SqlCredential
        }
        if (-not $PSDefaultParameterValues["*DbaDatabase:Database"]) {
            $PSDefaultParameterValues["*DbaDatabase:Database"] = $Database
        }
        if (-not $PSDefaultParameterValues["*DbaQuery:Database"]) {
            $PSDefaultParameterValues["*DbaQuery:Database"] = $Database
        }

        $querykey = "$servername-$username-$Database"

        if (-not $AssistantName) {
            Write-Verbose "No assistant name provided, generating default assistant name"
            $AssistantName = "query-$Database"
        }
        Write-Verbose "Using Assistant Name: $AssistantName"

        if (-not $script:threadcache[$querykey]) {
            Write-Verbose "Creating new thread cache object for key $querykey"
            $assistant = Get-Assistant -All | Where-Object Name -eq $AssistantName | Select-Object -First 1
            $cacheobject = [PSCustomObject]@{
                thread    = PSOpenAI\New-Thread
                assistant = $assistant
            }
            $script:threadcache[$querykey] = $cacheobject
        } else {
            Write-Verbose "Retrieving existing thread and assistant from cache"
            $thread = $script:threadcache[$querykey].thread
            $assistant = $script:threadcache[$querykey].assistant
        }

        $thread = $script:threadcache[$querykey].thread

        if (-not $assistant) {
            Write-Progress -Status "Retrieving or creating assistant" -PercentComplete ((2 / 10) * 100)
            Write-Verbose "Attempting to retrieve existing assistant named $AssistantName"
            $assistant = PSOpenAI\Get-Assistant -All | Where-Object Name -eq $AssistantName | Select-Object -First 1

            if (-not $assistant) {
                Write-Verbose "Assistant not found, creating a new assistant"
                try {
                    $assistant = Get-DbaDatabase -EnableException | New-DbaiAssistant -ErrorAction Stop
                } catch {
                    Write-Verbose "Error creating assistant: $_"
                    throw $PSItem
                }
            }

            $script:threadcache[$querykey].assistant = $assistant
        }

        $totalMessages = $Message.Count
        $processedMessages = 0
        $sentence = @()
        $msgs = @()
    }
    process {
        Write-Verbose "Processing input message"
        if ($Message -match '^\w+$' -or $Message -match '^\w{1}$') {
            Write-Verbose "Message is a single word or character, adding to sentence array"
            $sentence += "$Message"
        } else {
            Write-Verbose "Message is a full sentence, adding to message array"
            $msgs += $Message
        }
    }
    end {
        Write-Verbose "Finalizing message processing"
        if ($sentence.Length -gt 0) {
            Write-Verbose "Combining sentence array into a single message"
            $msgs += "$sentence"
        }

        foreach ($msg in $msgs) {
            Write-Verbose "Processing message: $msg"
            $messages = $rundata = $null
            Write-Progress -Status "Processing message $($processedMessages + 1) of $totalMessages" -PercentComplete ((1 / 10) * 100)

            Write-Verbose "Stopping any existing thread runs"
            $null = PSOpenAI\Get-ThreadRun -ThreadId $thread.id -ErrorAction SilentlyContinue | Where-Object status -in "queued", "in_progress",  "requires_action" | Stop-ThreadRun -ErrorAction SilentlyContinue
            Write-Verbose "Adding user message to thread"
            $null = PSOpenAI\Add-ThreadMessage -ThreadId $thread.id -Role user -Message $msg
            Write-Verbose "Starting new thread run with assistant $($assistant.Id)"
            $run = PSOpenAI\Start-ThreadRun -ThreadId $thread.id -Assistant $assistant.Id
            $PSDefaultParameterValues["*:RunId"] = $run.id

            Write-Progress -Status "Waiting for run to complete" -PercentComplete ((3 / 10) * 100)
            $rundata = PSOpenAI\Wait-ThreadRun -Run $run

            Write-Progress -Status "Current status: $($rundata.status)" -PercentComplete ((4 / 10) * 100)
            $rundata = PSOpenAI\Wait-ThreadRun -Run $rundata -StatusForWait @('queued', 'in_progress') -StatusForExit @('requires_action', 'completed')

            if ($rundata.status -eq "requires_action") {
                Write-Verbose "Run requires action: $($rundata.required_action.type)"
                $requiredAction = $rundata.required_action

                if ($requiredAction.type -eq "submit_tool_outputs") {
                    Write-Verbose "Submitting tool outputs"
                    $toolOutputs = $rundata.required_action.submit_tool_outputs.tool_calls
                    $arguments = "$($toolOutputs.function.arguments)".Replace('""', '"_empty"')
                    $arguments = $arguments | Select-Object -First 1

                    try {
                        if ($arguments -match '"query"') {
                            Write-Verbose "Parsed output is a SQL query"
                            $arguments = $arguments.replace('query:', '').Trim()
                            $sql = ($arguments | ConvertFrom-Json -ErrorAction Stop).query
                            $result = $null
                        } else {
                            Write-Verbose "Parsed output is an assistant answer"
                            $result = ($arguments | ConvertFrom-Json -ErrorAction Stop).answer
                            $sql = $null
                        }
                    } catch {
                        Write-Warning "Error parsing arguments: $_ Failed to parse arguments: $arguments"
                        continue
                    }

                    if ($sql) {
                        Write-Verbose "Executing SQL query: $sql"

                        if (-not $SkipSafetyCheck) {
                            Write-Progress -Status "Checking SQL query validity" -PercentComplete ((5 / 10) * 100)
                            $output = Test-SqlQuery -SqlStatement $sql

                            if (-not $output.Valid) {
                                Write-Warning "The SQL query ($sql) is not valid."
                                continue
                            }

                            if ($output.Dangerous) {
                                Write-Warning "The resulting SQL query ($sql) is dangerous because: $($output.DangerReason)"
                                continue
                            }

                            Write-Verbose "SQL query is valid and safe to execute"
                        }

                        Write-Progress -Status "Executing SQL query" -PercentComplete ((6 / 10) * 100)
                        $params = @{
                            "Query"           = $sql
                            "As"              = "PSObject"
                            "EnableException" = $true
                            "Database"        = $Database
                        }
                        try {
                            $result = Invoke-DbaQuery @params
                        } catch {
                            Write-Warning $_.Exception.Message
                            continue
                        }
                    } else {
                        Write-Verbose "No SQL query to execute, returning assistant's answer"
                        $result = $sql
                    }

                    if ($null -eq $result) {
                        Write-Verbose "No data returned from SQL query"
                        $output = "No data returned."
                    } else {
                        Write-Verbose "Converting result to JSON format"
                        $output = $result | Out-String | ConvertTo-Json -Depth 10
                    }

                    $innerToolOutputs = @()
                    foreach ($to in $toolOutputs) {
                        if ($to.id -as [string]) {
                            Write-Verbose "Adding tool output with ID $($to.id)"
                            $innerToolOutputs += @{
                                tool_call_id = [string]$to.id
                                output       = $output
                            }
                        }
                    }
                    $innerToolOutputs | ConvertTo-Json -Depth 10 | Write-Verbose
                    try {
                        Write-Verbose "Submitting tool outputs to assistant"
                        $null = PSOpenAI\Submit-ToolOutput -Run $rundata -ToolOutput $innerToolOutputs -ErrorAction Stop
                    } catch {
                        Write-Warning $_.Exception.Message
                        return
                    }

                    Write-Progress -Status "Waiting for run to complete" -PercentComplete ((7 / 10) * 100)
                    $rundata = PSOpenAI\Wait-ThreadRun -Run $rundata
                } else {
                    Write-Verbose "Unsupported required action type: $($requiredAction.type)"
                    throw "Unsupported required action type: $($requiredAction.type)"
                    break
                }
            }

            Write-Progress -Status "Run completed, waiting for answer" -PercentComplete ((8 / 10) * 100)
            $rundata = PSOpenAI\Wait-ThreadRun -Run $rundata
            Write-Verbose "Fetching assistant response message"
            $messages = PSOpenAI\Get-ThreadMessage -ThreadId $thread.id | Where-Object role -eq assistant | Select-Object -First 1

            if ($As -eq "String") {
                Write-Verbose "Returning result as string"
                $messages.content.text.value
            } elseif ($As -eq "PSObject") {
                Write-Verbose "Returning result as PSObject"
                [PSCustomObject]@{
                    Question     = $msg
                    Answer       = $messages.content.text.value
                    PromptTokens = $rundata.usage.prompt_tokens
                    Completion   = $rundata.usage.completion_tokens
                    TotalTokens  = $rundata.usage.total_tokens
                    SqlQuery     = $sql
                }
            }
        }
    }
}