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.

    .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 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"
        }
    }
}