Private/Parse-AIResponse.ps1
|
function Parse-AIResponse { <# .SYNOPSIS Parse AI response with multi-tier fallback strategy .DESCRIPTION Attempts to parse AI response as JSON using multiple strategies: 1. Direct JSON parse 2. Extract JSON from markdown code blocks 3. Extract any JSON array pattern 4. Return empty array on failure .PARAMETER ResponseText Raw text response from AI provider .EXAMPLE $violations = Parse-AIResponse -ResponseText $response .OUTPUTS System.Array - Array of violation objects .NOTES Author: waldo Version: 1.0.0 #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ResponseText ) begin { Write-Verbose "Starting $($MyInvocation.MyCommand.Name)" } process { try { # Extract PR comment text block from AI response $prCommentText = $null if ($ResponseText -match '(?s)```pr-comment\s*\n(.*?)\n```') { $prCommentText = $matches[1].Trim() Write-Verbose "Found PR comment text block ($($prCommentText.Length) chars)" # Display it in the pipeline output Write-Host "`n##[section]AI Review - PR Comment" Write-Host $prCommentText Write-Host "" } else { Write-Verbose "No pr-comment block found in AI response" } # Tier 1: Extract violations from code block (preferred format) if ($ResponseText -match '(?s)```violations\s*\n(.*?)\n```') { try { $json = $matches[1] | ConvertFrom-Json if (Test-ViolationArray -Violations $json) { Write-Verbose "Successfully parsed JSON (violations block)" return @{ Violations = (Select-RealViolations -Violations $json) PRCommentText = $prCommentText } } } catch { Write-Verbose "Violations block extraction failed: $_" } } # Tier 2: Extract JSON from json code blocks (fallback) if ($ResponseText -match '(?s)```json\s*\n(.*?)\n```') { try { $json = $matches[1] | ConvertFrom-Json if (Test-ViolationArray -Violations $json) { Write-Verbose "Successfully parsed JSON (json block)" return @{ Violations = (Select-RealViolations -Violations $json) PRCommentText = $prCommentText } } } catch { Write-Verbose "JSON block extraction failed: $_" } } # Tier 3: Direct JSON parse (fallback) try { $json = $ResponseText | ConvertFrom-Json if (Test-ViolationArray -Violations $json) { Write-Verbose "Successfully parsed JSON (direct parse)" return @{ Violations = $json PRCommentText = $prCommentText } } } catch { Write-Verbose "Direct JSON parse failed: $_" } # Tier 4: Try to extract any JSON array (last resort) if ($ResponseText -match '(?s)\[\s*\{.*?\}\s*\]') { try { $json = $matches[0] | ConvertFrom-Json if (Test-ViolationArray -Violations $json) { Write-Verbose "Successfully parsed JSON (array extraction)" return @{ Violations = (Select-RealViolations -Violations $json) PRCommentText = $prCommentText } } } catch { Write-Verbose "Array extraction failed: $_" } } # Tier 4: Give up gracefully Write-Verbose "Could not parse AI response as JSON. Raw response (truncated):" Write-Verbose $ResponseText.Substring(0, [Math]::Min(500, $ResponseText.Length)) return @{ Violations = @() PRCommentText = $prCommentText } } catch { Write-Error "Error in $($MyInvocation.MyCommand.Name): $_" throw } } end { Write-Verbose "Completed $($MyInvocation.MyCommand.Name)" } } function Test-ViolationArray { <# .SYNOPSIS Validate that parsed JSON matches expected violation schema .PARAMETER Violations Parsed JSON object to validate .OUTPUTS System.Boolean - True if valid, false otherwise #> param($Violations) if ($null -eq $Violations) { return $false } # Allow empty arrays if ($Violations -is [array] -and $Violations.Count -eq 0) { return $true } # Ensure it's an array if ($Violations -isnot [array]) { $Violations = @($Violations) } # Validate schema foreach ($v in $Violations) { if (-not $v.PSObject.Properties['file']) { Write-Verbose "Missing 'file' property" return $false } if (-not $v.PSObject.Properties['line']) { Write-Verbose "Missing 'line' property" return $false } if (-not $v.PSObject.Properties['severity']) { Write-Verbose "Missing 'severity' property" return $false } if (-not $v.PSObject.Properties['message']) { Write-Verbose "Missing 'message' property" return $false } # Validate severity values if ($v.severity -notin @('error', 'warning', 'info')) { Write-Verbose "Invalid severity: $($v.severity)" return $false } } return $true } function Select-RealViolations { <# .SYNOPSIS Drop entries that are not violations before anything is posted .DESCRIPTION Models use the violations array as a scratchpad: they reason about a rule, conclude "no violation" and leave the entry in anyway (6 of 9 warnings on iFactoApps PR 8040, 2026-10-07, on claude-sonnet-4-6). The system prompt forbids it; this makes it deterministic. An entry is dropped when it declares `violates: false`, or when its message concludes that nothing is wrong. Every drop is logged. .PARAMETER Violations Parsed violations array .OUTPUTS System.Array - the entries that are violations #> param($Violations) $kept = @() $dropped = 0 foreach ($v in @($Violations)) { $message = [string]$v.message $declaredFalse = $v.PSObject.Properties['violates'] -and ("$($v.violates)" -eq 'False') $concludesNone = $message -match '(?i)\b(no violation|not a violation|no finding|no action (is )?needed|complies with (the|this) rule|correct per (the )?rule)\b' if ($declaredFalse -or $concludesNone) { $dropped++ $why = if ($declaredFalse) { 'violates=false' } else { 'message concludes no violation' } Write-Host " dropped non-violation ($why): $($v.file):$($v.line) - $($message.Substring(0, [Math]::Min(100, $message.Length)))" continue } $kept += $v } if ($dropped -gt 0) { Write-Host "Filtered $dropped self-declared non-violation(s) out of the violations array" } return ,$kept } |