Private/Invoke-JevDecision.ps1

#requires -Version 7.0

Set-StrictMode -Version Latest

function Invoke-JevDecision {
    <#
    .SYNOPSIS
        Sends one state value and a named question dictionary to Jev.
 
    .DESCRIPTION
        Validates and normalizes questions, calls the Jev endpoint with retries,
        or returns a local mock response when requested.
 
    .PARAMETER State
        The string, object, or array all questions should evaluate.
 
    .PARAMETER Questions
        A hashtable whose values are typed Jev questions. The wrapper accepts
        either lower-case API types or the readable Noul/Choice/Score spellings.
 
    .PARAMETER Model
        The model name to send with the request.
 
    .PARAMETER Endpoint
        The Jev API endpoint.
 
    .PARAMETER TimeoutSec
        Maximum time, in seconds, for each HTTP request.
 
    .PARAMETER MaxRetries
        Number of retries for transient HTTP failures.
 
    .PARAMETER RetryDelayMs
        Initial delay, in milliseconds, before retrying a transient failure.
 
    .PARAMETER UseMock
        Return a schema-compatible local response without making an HTTP call.
 
    .PARAMETER MockOnMissingKey
        Use mock mode only when TYPESAFE_API_KEY is missing. HTTP failures still
        fail loudly so production problems are not hidden.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [AllowNull()]
        [object] $State,

        [Parameter(Mandatory, Position = 1)]
        [System.Collections.IDictionary] $Questions,

        [string] $Model = 'jev-latest',

        [uri] $Endpoint = 'https://api.typesafe.ai/v1/systemone',

        [ValidateRange(1, 300)]
        [int] $TimeoutSec = 30,

        [ValidateRange(0, 5)]
        [int] $MaxRetries = 2,

        [ValidateRange(0, 10000)]
        [int] $RetryDelayMs = 250,

        [switch] $UseMock,

        [switch] $MockOnMissingKey
    )

    process {
        if ($null -eq $State) {
            throw 'State cannot be null.'
        }
        if ($Questions.Count -eq 0) {
            throw 'Questions must contain at least one named question.'
        }

        $normalizedQuestions = [ordered] @{}
        foreach ($questionEntry in $Questions.GetEnumerator()) {
            $questionName = [string] $questionEntry.Key
            if ([string]::IsNullOrWhiteSpace($questionName)) {
                throw 'Every Jev question must have a non-empty name.'
            }
            if ($null -eq $questionEntry.Value) {
                throw "Question '$questionName' cannot be null."
            }

            $question = ConvertTo-JevDictionary -Value $questionEntry.Value
            if (-not $question.Contains('type')) {
                throw "Question '$questionName' is missing its type. Use Noul, Choice, or Score."
            }
            $type = ([string] $question.type).ToLowerInvariant()
            if ($type -notin @('noul', 'choice', 'score')) {
                throw "Question '$questionName' has unsupported type '$($question.type)'. Use Noul, Choice, or Score."
            }

            $normalizedQuestion = @{ type = $type }
            if ($question.Contains('instructions')) {
                $normalizedQuestion['instructions'] = $question['instructions']
            }

            switch ($type) {
                'noul' {
                    if ($question.Contains('criteria')) { $normalizedQuestion['criteria'] = $question['criteria'] }
                }
                'choice' {
                    if (-not $question.Contains('criteria')) { throw "Choice question '$questionName' requires criteria." }
                    $criteria = ConvertTo-JevDictionary -Value $question.criteria
                    if ($criteria.Count -eq 0) { throw "Choice question '$questionName' requires at least one criterion." }
                    $normalizedQuestion['criteria'] = $criteria
                }
                'score' {
                    if (-not $question.Contains('criteria')) { throw "Score question '$questionName' requires criteria." }
                    $criteria = @($question.criteria)
                    if ($criteria.Count -eq 0) { throw "Score question '$questionName' requires at least one level." }
                    $normalizedQuestion['criteria'] = $criteria
                }
            }

            $normalizedQuestions[$questionName] = $normalizedQuestion
        }

        $apiKey = [string] $env:TYPESAFE_API_KEY
        $shouldMock = $UseMock.IsPresent -or ($MockOnMissingKey.IsPresent -and [string]::IsNullOrWhiteSpace($apiKey))

        if ($shouldMock) {
            return Invoke-JevMockDecision -State $State -Questions $normalizedQuestions -Model $Model
        }

        if ([string]::IsNullOrWhiteSpace($apiKey)) {
            throw 'TYPESAFE_API_KEY is not set. Set it or use -UseMock for offline testing.'
        }

        $request = [ordered] @{
            state     = $State
            model     = $Model
            questions = $normalizedQuestions
        }
        $body = $request | ConvertTo-Json -Depth 30 -Compress

        try {
            # The public API currently calls this endpoint /v1/systemone.
            for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) {
                try {
                    return Invoke-RestMethod `
                        -Uri $Endpoint `
                        -Method Post `
                        -Headers @{ Authorization = "Bearer $apiKey" } `
                        -ContentType 'application/json' `
                        -Body $body `
                        -TimeoutSec $TimeoutSec `
                        -ErrorAction Stop
                }
                catch {
                    $statusCode = 0
                    try {
                        if ($null -ne $_.Exception.Response) {
                            $statusCode = [int] $_.Exception.Response.StatusCode.value__
                        }
                    }
                    catch {
                        # Some PowerShell/.NET exception types do not expose a status code.
                    }

                    $retryable = $statusCode -in @(408, 425, 429) -or $statusCode -ge 500
                    $lastAttempt = $attempt -ge $MaxRetries
                    if (-not $retryable -or $lastAttempt) {
                        throw
                    }

                    # Exponential backoff prevents a busy service or rate limit from
                    # turning one pipeline item into a tight retry loop.
                    $delay = [math]::Min(10000, $RetryDelayMs * [math]::Pow(2, $attempt))
                    Start-Sleep -Milliseconds ([int] $delay)
                }
            }
        }
        catch {
            $details = Get-JevErrorBody -Exception $_.Exception
            throw "Jev request failed at '$Endpoint': $details"
        }
    }
}