PSAIClairic.psm1
|
Set-StrictMode -Version Latest function New-ClairicField { <# .SYNOPSIS Creates a typed Clairic field definition. .EXAMPLE New-ClairicField -Type number -Unit USD -Decimals 2 -Instructions 'Total including shipping' #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('text','number','date','time','datetime','duration','list','estimate','forecast')][string]$Type, [string]$Instructions, [string]$Unit, [switch]$Integer, [ValidateRange(0,6)][int]$Decimals, [ValidateSet('text','number','date')][string]$Items, [object]$Min, [object]$Max, [object]$Step, [ValidateSet('linear','log')][string]$Scale, [ValidateRange(0.0000000001,1)][double]$NullThreshold, [ValidateRange(1,64)][int]$Horizon, [ValidateSet('step','minute','hour','day','week','month','quarter','year')][string]$Interval ) $field = @{ type = $Type.ToLowerInvariant() } $names = @{ Instructions='instructions'; Unit='unit'; Integer='integer'; Decimals='decimals'; Items='items'; Min='min'; Max='max'; Step='step'; Scale='scale'; NullThreshold='null_threshold'; Horizon='horizon'; Interval='interval' } foreach ($name in $names.Keys) { if ($PSBoundParameters.ContainsKey($name)) { $value = $PSBoundParameters[$name] if ($name -eq 'Integer') { $value = [bool]$value } $field[$names[$name]] = $value } } Assert-ClairicField -Name field -Field $field return $field } function Assert-ClairicField { param([string]$Name, [object]$Field) if ($Name -cnotmatch '^[A-Za-z_][A-Za-z0-9_.\-]{0,63}$') { throw "Invalid field name '$Name'. Use 1-64 letters, digits, underscores, dots or hyphens; start with a letter or underscore." } if ($Field -isnot [System.Collections.IDictionary]) { throw "Field '$Name' must be a hashtable or dictionary." } # Use the vendored official schema for both helpers and caller-supplied fields. $fieldSchema = $script:InputSchema.properties.fields.additionalProperties | ConvertTo-Json -Depth 50 $json = ConvertTo-Json -InputObject $Field -Depth 50 -Compress if (-not (Test-Json -Json $json -Schema $fieldSchema -ErrorAction SilentlyContinue)) { throw "Field '$Name' does not match the Clairic field schema." } $type = $Field['type'] if ($type -eq 'list' -and -not $Field.Contains('items')) { throw "List field '$Name' requires items." } if ($type -in @('estimate','forecast')) { foreach ($key in @('min','max')) { if (-not $Field.Contains($key) -or $null -eq $Field[$key]) { throw "Field '$Name' requires $key." } } $lower = 0.0; $upper = 0.0 $numeric = [double]::TryParse([string]$Field['min'], [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$lower) -and [double]::TryParse([string]$Field['max'], [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$upper) if ($numeric) { if (-not [double]::IsFinite($lower) -or -not [double]::IsFinite($upper) -or $lower -ge $upper) { throw "Field '$Name' requires finite min < max." } if ($Field['scale'] -eq 'log' -and $lower -le 0) { throw "Log field '$Name' requires min > 0." } } else { $dateMin = [datetime]::MinValue; $dateMax = [datetime]::MinValue if (-not [datetime]::TryParseExact([string]$Field['min'],'yyyy-MM-dd',[cultureinfo]::InvariantCulture,[Globalization.DateTimeStyles]::None,[ref]$dateMin) -or -not [datetime]::TryParseExact([string]$Field['max'],'yyyy-MM-dd',[cultureinfo]::InvariantCulture,[Globalization.DateTimeStyles]::None,[ref]$dateMax) -or $dateMin -ge $dateMax) { throw "Field '$Name' requires numeric bounds or ISO dates with min < max." } } if ($Field.Contains('step') -and $Field['step'] -notin @('month','year')) { $stepValue = 0.0 if (-not [double]::TryParse([string]$Field['step'], [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$stepValue) -or -not [double]::IsFinite($stepValue) -or $stepValue -le 0) { throw "Field '$Name' requires a positive step, 'month', or 'year'." } } } } function Invoke-Clairic { <# .SYNOPSIS Sends a typed extraction request to Cloudflare Workers AI Clairic. .DESCRIPTION Uses CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN by default. Returns the complete result including uncertainty and usage metadata. PreviewRequest builds the JSON without credentials or a network request. .EXAMPLE Invoke-Clairic -State 'Invoice A123. Total USD 10.' -Fields @{ total = @{ type='number'; unit='USD' } } #> [CmdletBinding()] param( [Parameter(ValueFromPipeline)][AllowEmptyString()][string]$State = '', [Parameter(Mandatory)][System.Collections.IDictionary]$Fields, [string[]]$ImagePath, [string]$ReferenceDate, [switch]$IncludeQuantiles, [switch]$ReturnStateText, [string]$AccountId = $env:CLOUDFLARE_ACCOUNT_ID, [string]$ApiToken = $env:CLOUDFLARE_AUTH_TOKEN, [ValidateRange(1,1800)][int]$TimeoutSec = 120, [switch]$PreviewRequest ) process { if ($Fields.Count -eq 0) { throw 'At least one field is required.' } foreach ($name in $Fields.Keys) { Assert-ClairicField -Name $name -Field $Fields[$name] } $body = @{ model='clairic'; state=$State; fields=$Fields } if ($PSBoundParameters.ContainsKey('ReferenceDate')) { $parsedDate = [datetime]::MinValue if (-not [datetime]::TryParseExact($ReferenceDate,'yyyy-MM-dd',[cultureinfo]::InvariantCulture,[Globalization.DateTimeStyles]::None,[ref]$parsedDate)) { throw 'ReferenceDate must be an ISO date (yyyy-MM-dd).' } $body.reference_date = $ReferenceDate } if ($IncludeQuantiles -or $ReturnStateText) { $body.options = @{ include_quantiles=[bool]$IncludeQuantiles; return_state_text=[bool]$ReturnStateText } } if ($ImagePath -and $ImagePath.Count -gt 4) { throw 'At most four images are allowed.' } $images = @(); $totalBytes = 0L foreach ($path in $ImagePath) { $file = Get-Item -LiteralPath $path -ErrorAction Stop if ($file.PSIsContainer -or $file.Length -gt 4MB) { throw 'Each image must be a file no larger than 4 MiB.' } $contentType = switch ($file.Extension.ToLowerInvariant()) { '.png' { 'image/png' }; '.jpg' { 'image/jpeg' }; '.jpeg' { 'image/jpeg' }; '.webp' { 'image/webp' } default { throw 'Only PNG, JPEG and WebP image files are supported.' } } $bytes = [IO.File]::ReadAllBytes($file.FullName) $totalBytes += $bytes.Length if ($totalBytes -gt 8MB) { throw 'Combined decoded images exceed 8 MiB.' } $images += @{ content_type=$contentType; base64=[Convert]::ToBase64String($bytes) } } if ($images.Count) { $body.images = $images } if ([string]::IsNullOrWhiteSpace($State) -and $images.Count -eq 0) { throw 'Provide non-empty State or at least one image.' } $json = ConvertTo-Json -InputObject $body -Depth 50 -Compress $requestBytes = [Text.Encoding]::UTF8.GetBytes($json) if ($requestBytes.Length -gt 16MB) { throw 'Request body exceeds 16 MiB.' } if ($PreviewRequest) { return $json } if ($AccountId -cnotmatch '^[a-fA-F0-9]{32}$') { throw 'Set CLOUDFLARE_ACCOUNT_ID or supply a 32-character hexadecimal AccountId.' } if ([string]::IsNullOrWhiteSpace($ApiToken)) { throw 'Set CLOUDFLARE_AUTH_TOKEN or supply ApiToken.' } $uri = "https://api.cloudflare.com/client/v4/accounts/$AccountId/ai/run/@cf/cloudflare/clairic" try { $response = Invoke-RestMethod -Uri $uri -Method Post -Headers @{ Authorization="Bearer $ApiToken" } -ContentType 'application/json; charset=utf-8' -Body $requestBytes -TimeoutSec $TimeoutSec -ErrorAction Stop } catch { # Do not include request headers, response bodies or credentials in errors. $status = $null if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } $message = if ($status) { "Clairic HTTP request failed (status $status). Check Cloudflare permissions, quota and request fields." } else { 'Clairic request failed. Check connectivity and timeout settings.' } throw [InvalidOperationException]::new($message) } if ($null -eq $response) { throw 'Cloudflare returned an empty response.' } if ($response.PSObject.Properties['success']) { if (-not $response.success) { throw 'Cloudflare reported an unsuccessful Clairic request.' } if (-not $response.PSObject.Properties['result']) { throw 'Cloudflare response is missing result.' } $response = $response.result } if ($null -eq $response -or -not $response.PSObject.Properties['answers']) { throw 'Clairic response is missing answers.' } if ($response.PSObject.Properties['truncated'] -and $response.truncated) { Write-Warning 'Clairic truncated the input. Review results before use.' } return $response } } function ConvertFrom-ClairicAnswer { <# .SYNOPSIS Projects a Clairic result into an object containing field values only. .DESCRIPTION Explicit convenience projection. Discards confidence, spans and usage; keep the original response when evaluating uncertainty. Preserves null values. FieldOrder places named fields first; remaining fields retain response order. #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)][psobject]$InputObject, [string[]]$FieldOrder ) process { if (-not $InputObject.PSObject.Properties['answers']) { throw 'Input must be a Clairic result containing answers.' } $values = [ordered]@{} foreach ($name in $FieldOrder) { $property = $InputObject.answers.PSObject.Properties[$name] if ($null -eq $property) { throw "Answer field '$name' does not exist." } $values[$name] = $property.Value.value } foreach ($property in $InputObject.answers.PSObject.Properties) { if (-not $values.Contains($property.Name)) { $values[$property.Name] = $property.Value.value } } [pscustomobject]$values } } $script:InputSchema = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'schemas/schema-input.json') -Raw | ConvertFrom-Json Export-ModuleMember -Function New-ClairicField, Invoke-Clairic, ConvertFrom-ClairicAnswer |