microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
scripts/evals/Invoke-ArtifactModeration.ps1
176lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | #Requires -Version 7.0 |
| 5 | |
| 6 | <# |
| 7 | .SYNOPSIS |
| 8 | Content-moderation pre-job for all eval specs plus changed AI artifacts. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Enumerates every stimulus / eval spec file under the eval root and combines |
| 12 | them with the agent/prompt/instruction/skill files in the PR change set |
| 13 | (read from the changed-artifact manifest). The combined, de-duplicated file |
| 14 | list is moderated whole-file via Invoke-ContentModeration.ps1. |
| 15 | |
| 16 | Designed to run as an isolated pre-job before vally eval execution so that |
| 17 | flagged content blocks the pipeline with a dedicated, clean log. Exit code |
| 18 | is propagated from the moderation wrapper: 0 clean, 1 flagged (block), |
| 19 | 2 error. |
| 20 | |
| 21 | .PARAMETER ManifestPath |
| 22 | Path to the changed-artifact manifest. Defaults to |
| 23 | logs/changed-ai-artifacts.json. When absent, only eval specs are moderated. |
| 24 | |
| 25 | .PARAMETER EvalRoot |
| 26 | Root directory of eval specs. Defaults to evals. |
| 27 | |
| 28 | .PARAMETER OutFile |
| 29 | Output path for moderation results. Defaults to |
| 30 | logs/moderation-artifacts.json. |
| 31 | |
| 32 | .PARAMETER Threshold |
| 33 | Toxicity threshold (0.0-1.0). Defaults to 0.5. |
| 34 | |
| 35 | .PARAMETER Model |
| 36 | Detoxify model variant: original, unbiased, multilingual. Defaults to |
| 37 | unbiased. |
| 38 | |
| 39 | .PARAMETER RepoRoot |
| 40 | Repository root directory. Defaults to git repo root or script directory. |
| 41 | |
| 42 | .NOTES |
| 43 | Runs via: npm run eval:moderate:artifacts |
| 44 | #> |
| 45 | [CmdletBinding()] |
| 46 | param( |
| 47 | [Parameter(Mandatory = $false)] |
| 48 | [string]$ManifestPath, |
| 49 | |
| 50 | [Parameter(Mandatory = $false)] |
| 51 | [string]$EvalRoot, |
| 52 | |
| 53 | [Parameter(Mandatory = $false)] |
| 54 | [string]$OutFile, |
| 55 | |
| 56 | [Parameter(Mandatory = $false)] |
| 57 | [double]$Threshold = 0.5, |
| 58 | |
| 59 | [Parameter(Mandatory = $false)] |
| 60 | [ValidateSet('original', 'unbiased', 'multilingual')] |
| 61 | [string]$Model = 'unbiased', |
| 62 | |
| 63 | [Parameter(Mandatory = $false)] |
| 64 | [string]$RepoRoot |
| 65 | ) |
| 66 | |
| 67 | $ErrorActionPreference = 'Stop' |
| 68 | |
| 69 | function Resolve-RepoRoot { |
| 70 | [CmdletBinding()] |
| 71 | [OutputType([string])] |
| 72 | param([string]$Hint) |
| 73 | |
| 74 | if (-not [string]::IsNullOrWhiteSpace($Hint)) { |
| 75 | return (Resolve-Path -LiteralPath $Hint).ProviderPath |
| 76 | } |
| 77 | |
| 78 | try { |
| 79 | $gitRoot = git rev-parse --show-toplevel 2>$null |
| 80 | if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($gitRoot)) { |
| 81 | return (Resolve-Path -LiteralPath $gitRoot.Trim()).ProviderPath |
| 82 | } |
| 83 | } |
| 84 | catch { $null = $_ } |
| 85 | |
| 86 | return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).ProviderPath |
| 87 | } |
| 88 | |
| 89 | function Resolve-PathFromRoot { |
| 90 | [CmdletBinding()] |
| 91 | [OutputType([string])] |
| 92 | param( |
| 93 | [Parameter(Mandatory = $true)][string]$Path, |
| 94 | [Parameter(Mandatory = $true)][string]$RepoRoot |
| 95 | ) |
| 96 | |
| 97 | if ([System.IO.Path]::IsPathRooted($Path)) { return $Path } |
| 98 | return (Join-Path -Path $RepoRoot -ChildPath $Path) |
| 99 | } |
| 100 | |
| 101 | if ($MyInvocation.InvocationName -eq '.') { return } |
| 102 | |
| 103 | $resolvedRoot = Resolve-RepoRoot -Hint $RepoRoot |
| 104 | |
| 105 | if ([string]::IsNullOrWhiteSpace($ManifestPath)) { $ManifestPath = 'logs/changed-ai-artifacts.json' } |
| 106 | if ([string]::IsNullOrWhiteSpace($EvalRoot)) { $EvalRoot = 'evals' } |
| 107 | if ([string]::IsNullOrWhiteSpace($OutFile)) { $OutFile = 'logs/moderation-artifacts.json' } |
| 108 | |
| 109 | $resolvedManifest = Resolve-PathFromRoot -Path $ManifestPath -RepoRoot $resolvedRoot |
| 110 | $resolvedEvalRoot = Resolve-PathFromRoot -Path $EvalRoot -RepoRoot $resolvedRoot |
| 111 | $resolvedOutFile = Resolve-PathFromRoot -Path $OutFile -RepoRoot $resolvedRoot |
| 112 | |
| 113 | if (-not (Test-Path -LiteralPath $resolvedEvalRoot -PathType Container)) { |
| 114 | Write-Host "::error::Eval root not found: $resolvedEvalRoot" |
| 115 | exit 2 |
| 116 | } |
| 117 | |
| 118 | $outDir = Split-Path -Parent $resolvedOutFile |
| 119 | if ($outDir -and -not (Test-Path -LiteralPath $outDir)) { |
| 120 | New-Item -ItemType Directory -Path $outDir -Force | Out-Null |
| 121 | } |
| 122 | |
| 123 | $fileSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 124 | |
| 125 | # Collect every stimulus / eval spec file under the eval root. |
| 126 | $specFiles = Get-ChildItem -LiteralPath $resolvedEvalRoot -Recurse -File -Include '*.yaml', '*.yml' -ErrorAction SilentlyContinue |
| 127 | foreach ($spec in $specFiles) { |
| 128 | $null = $fileSet.Add($spec.FullName) |
| 129 | } |
| 130 | |
| 131 | # Collect changed artifact files from the manifest (skip deletions and missing files). |
| 132 | if (Test-Path -LiteralPath $resolvedManifest -PathType Leaf) { |
| 133 | $manifest = Get-Content -LiteralPath $resolvedManifest -Raw | ConvertFrom-Json |
| 134 | $artifacts = @() |
| 135 | if ($null -ne $manifest -and $null -ne $manifest.artifacts) { |
| 136 | $artifacts = @($manifest.artifacts | Where-Object { [string]$_.status -ne 'D' }) |
| 137 | } |
| 138 | |
| 139 | foreach ($artifact in $artifacts) { |
| 140 | $artifactPath = [string]$artifact.path |
| 141 | if ([string]::IsNullOrWhiteSpace($artifactPath)) { continue } |
| 142 | $absolute = Resolve-PathFromRoot -Path $artifactPath -RepoRoot $resolvedRoot |
| 143 | if (Test-Path -LiteralPath $absolute -PathType Leaf) { |
| 144 | $null = $fileSet.Add(((Resolve-Path -LiteralPath $absolute).ProviderPath)) |
| 145 | } |
| 146 | else { |
| 147 | Write-Warning "Artifact file not found: $artifactPath" |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | else { |
| 152 | Write-Warning "Manifest not found: $resolvedManifest; moderating eval specs only." |
| 153 | } |
| 154 | |
| 155 | $files = @($fileSet) |
| 156 | |
| 157 | if ($files.Count -eq 0) { |
| 158 | $empty = [ordered]@{ |
| 159 | scope = 'artifacts' |
| 160 | model = $Model |
| 161 | threshold = $Threshold |
| 162 | flagged = $false |
| 163 | results = @() |
| 164 | } |
| 165 | $empty | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $resolvedOutFile -Encoding utf8NoBOM |
| 166 | Write-Host "No eval specs or changed AI artifacts to moderate. Empty log written to $resolvedOutFile" |
| 167 | exit 0 |
| 168 | } |
| 169 | |
| 170 | Write-Host "Moderating $($files.Count) file(s) (all eval specs + changed artifacts)." |
| 171 | |
| 172 | $moderationScript = Join-Path $PSScriptRoot 'Invoke-ContentModeration.ps1' |
| 173 | & $moderationScript -FileList $files -Scope 'artifacts' -OutFile $resolvedOutFile -Threshold $Threshold -Model $Model -RepoRoot $resolvedRoot |
| 174 | $exitCode = $LASTEXITCODE |
| 175 | |
| 176 | exit $exitCode |
| 177 | |