Public/Invoke-AphorismBatch.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. <# .SYNOPSIS Batch-generates camp-voiced sober aphorisms for POV taxonomy nodes (t/1550). .DESCRIPTION Reads the three POV taxonomy files (accelerationist/safetyist/skeptic) and generates `graph_attributes.aphorism` for each node using the `enrichment.pov-aphorism` UsageID. Situations are out of scope v1. Presentational field only — never a scoring/relevance/embedding input. Skips: - Nodes that already have a non-empty aphorism (unless -Force) - Nodes whose description begins "A thematic pillar" — pillars get themed hooks under a separate owner-review track; leave empty v1 - Deprecated nodes ([DEPRECATED] prefix) - Nodes with empty or very short (< 20 char) descriptions Regeneration on modify (label/description edits from the write paths in Invoke-ProposalApply and Repair-PovDescriptions) is handled by a separate fail-open helper — this cmdlet is the bulk backfill entry point. .PARAMETER TaxonomyPath POV taxonomy directory. Defaults to Get-TaxonomyDir. .PARAMETER Concurrency Parallel AI calls. Default: 10. .PARAMETER Force Regenerate even for nodes that already have an aphorism. .PARAMETER Id Restrict to specific node ID(s). Useful for the CL 20-node sample review. .EXAMPLE Invoke-AphorismBatch .EXAMPLE # Generate the CL-review sample without touching the rest Invoke-AphorismBatch -Id acc-desires-001,saf-beliefs-001,skp-intentions-001 .EXAMPLE Invoke-AphorismBatch -Force -Concurrency 5 .LINK Show-AITriadHelp .LINK Invoke-AIByUsage .LINK Invoke-BDIWeightAssignment .LINK Invoke-EdgeWeightEvaluation .LINK Invoke-VernacularBatch .LINK New-SyntheticCorpus #> function Invoke-AphorismBatch { [CmdletBinding(SupportsShouldProcess)] param( [Parameter()][string]$TaxonomyPath, [Parameter()][ValidateRange(1, 50)][int]$Concurrency = 10, [switch]$Force, [Parameter()] [Alias('NodeId')] [string[]]$Id ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $TaxonomyPath) { $TaxonomyPath = Get-TaxonomyDir } if (-not (Test-Path $TaxonomyPath)) { throw (New-ActionableError ` -Goal 'Generate POV node aphorisms' ` -Problem "Taxonomy directory not found: $TaxonomyPath" ` -Location 'Invoke-AphorismBatch' ` -NextSteps @('Check that ai-triad-data is available', 'Set $env:AI_TRIAD_DATA_ROOT or verify .aitriad.json')) } # Situations are out of scope v1 — POV files only. $TaxFiles = @('accelerationist.json', 'safetyist.json', 'skeptic.json') # Prompt lives in the .prompt file (ADR-006). CL-approved text (t/1550#2) # embeds NODE data as {{pov}}/{{category}}/{{label}}/{{description}} — we # render those per-call via Get-Prompt -Replacements, then pass the fully # rendered text to the UsageID as {{prompt}}. Prompt template loaded lazily # inside the parallel scriptblock to avoid $using capture of a large string # per-node; instead we pass Model + Concurrency and let each worker load. $NodesToProcess = [System.Collections.Generic.List[PSCustomObject]]::new() $SkippedExisting = 0 $SkippedPillar = 0 $SkippedDeprecated = 0 $SkippedEmpty = 0 $SkippedByIdFilter = 0 foreach ($FileName in $TaxFiles) { $FilePath = Join-Path $TaxonomyPath $FileName if (-not (Test-Path $FilePath)) { Write-Warning "Taxonomy file not found, skipping: $FileName" continue } Write-Verbose "Processing taxonomy file: $FileName" $TaxData = Get-Content $FilePath -Raw | ConvertFrom-Json $PovName = if ($TaxData.PSObject.Properties['pov']) { [string]$TaxData.pov } else { '' } $NodeIndex = 0 foreach ($Node in @($TaxData.nodes)) { if ($null -ne $Id -and @($Id).Count -gt 0 -and ($Node.id -notin $Id)) { $SkippedByIdFilter++ $NodeIndex++ continue } $Desc = $null if ($Node.PSObject.Properties['description']) { $Desc = $Node.description } $LabelStr = '' if ($Node.PSObject.Properties['label']) { $LabelStr = [string]$Node.label } if ([string]::IsNullOrWhiteSpace($Desc) -or $Desc.Length -lt 20) { $SkippedEmpty++ $NodeIndex++ continue } # Deprecation marker may live on either label or description (CL review t/1550#4). if ($Desc.StartsWith('[DEPRECATED]') -or $LabelStr.StartsWith('[DEPRECATED]')) { $SkippedDeprecated++ $NodeIndex++ continue } if ($Desc.StartsWith('A thematic pillar')) { $SkippedPillar++ Write-Verbose "Skipping pillar node $($Node.id) (v1 excludes pillars)" $NodeIndex++ continue } if (-not $Force) { $HasAphorism = $false if ($Node.PSObject.Properties['graph_attributes'] -and $null -ne $Node.graph_attributes -and $Node.graph_attributes.PSObject.Properties['aphorism']) { $HasAphorism = -not [string]::IsNullOrWhiteSpace($Node.graph_attributes.aphorism) } if ($HasAphorism) { $SkippedExisting++ $NodeIndex++ continue } } $Label = if ($Node.PSObject.Properties['label']) { [string]$Node.label } else { '' } $Category = if ($Node.PSObject.Properties['category']) { [string]$Node.category } else { '' } # Pre-render prompt in this (outer) runspace — Get-Prompt is a Private # module helper and isn't visible inside ForEach-Object -Parallel's # per-worker runspaces. Passing the rendered text via the work item # sidesteps that entirely. $RenderedPrompt = Get-Prompt -Name 'pov-aphorism' -Replacements @{ pov = $PovName category = $Category label = $Label description = $Desc } $NodesToProcess.Add([PSCustomObject]@{ File = $FileName FilePath = $FilePath NodeId = $Node.id NodeIndex = $NodeIndex Pov = $PovName Label = $Label Category = $Category Desc = $Desc Prompt = $RenderedPrompt }) $NodeIndex++ } } $Total = $NodesToProcess.Count Write-Verbose "Nodes to process: $Total" Write-Verbose "Skipped up-to-date: $SkippedExisting" Write-Verbose "Skipped pillars: $SkippedPillar" Write-Verbose "Skipped deprecated: $SkippedDeprecated" Write-Verbose "Skipped empty/short: $SkippedEmpty" Write-Verbose "Skipped by -Id filter: $SkippedByIdFilter" if ($Total -eq 0) { Write-Host 'Nothing to generate — all nodes already have aphorisms or were filtered out.' return [PSCustomObject]@{ Generated = 0 Skipped = $SkippedExisting + $SkippedPillar + $SkippedDeprecated + $SkippedEmpty + $SkippedByIdFilter Failed = 0 } } $Generated = [System.Collections.Concurrent.ConcurrentBag[string]]::new() $Failed = [System.Collections.Concurrent.ConcurrentBag[string]]::new() $Results = [System.Collections.Concurrent.ConcurrentDictionary[string, System.Collections.Concurrent.ConcurrentDictionary[int, PSCustomObject]]]::new() $ModulePath = Join-Path $script:ModuleRoot 'AITriad.psm1' $EnrichPath = Join-Path $script:ModuleRoot '..' 'AIEnrich.psm1' if (-not (Test-Path $EnrichPath)) { $EnrichPath = Join-Path $script:ModuleRoot 'AIEnrich.psm1' } $ProgressId = 1 $Completed = [ref]0 Write-Progress -Id $ProgressId -Activity 'Generating aphorisms' -Status "0 / $Total" -PercentComplete 0 if ($Concurrency -eq 1) { # Sequential path — same runspace, no parallel-scriptblock isolation. # Used for tests (mocks work) and for CL's 20-sample review runs where # deterministic ordering matters more than throughput. foreach ($Item in $NodesToProcess) { try { $AIResult = Invoke-AIByUsage -UsageId 'enrichment.pov-aphorism' -Values @{ prompt = $Item.Prompt } if ($null -ne $AIResult -and -not [string]::IsNullOrWhiteSpace($AIResult.Text)) { $Aphorism = $AIResult.Text.Trim().Trim('"').Trim() $FileDict = $Results.GetOrAdd($Item.FilePath, [System.Collections.Concurrent.ConcurrentDictionary[int, PSCustomObject]]::new()) [void]$FileDict.TryAdd($Item.NodeIndex, [PSCustomObject]@{ Aphorism = $Aphorism }) [void]$Generated.Add($Item.NodeId) } else { Write-Warning "$($Item.NodeId): AI returned empty response" [void]$Failed.Add($Item.NodeId) } } catch { Write-Warning "$($Item.NodeId): $($_.Exception.Message)" [void]$Failed.Add($Item.NodeId) } $Done = [System.Threading.Interlocked]::Increment($Completed) $Pct = [math]::Min(100, [math]::Round(($Done / $Total) * 100)) Write-Progress -Id $ProgressId -Activity 'Generating aphorisms' -Status "$Done / $Total" -PercentComplete $Pct } } else { $NodesToProcess | ForEach-Object -Parallel { Import-Module $using:ModulePath -Force -WarningAction SilentlyContinue Import-Module $using:EnrichPath -Force -WarningAction SilentlyContinue $Item = $_ $GenBag = $using:Generated $FailBag = $using:Failed $ResultsDict = $using:Results $CompRef = $using:Completed $TotalCount = $using:Total $ProgId = $using:ProgressId try { # Prompt was rendered in the outer runspace and travels with the work # item — Get-Prompt is a Private helper and isn't visible here. $AIResult = Invoke-AIByUsage -UsageId 'enrichment.pov-aphorism' -Values @{ prompt = $Item.Prompt } if ($null -ne $AIResult -and -not [string]::IsNullOrWhiteSpace($AIResult.Text)) { $Aphorism = $AIResult.Text.Trim().Trim('"').Trim() $FileDict = $ResultsDict.GetOrAdd($Item.FilePath, [System.Collections.Concurrent.ConcurrentDictionary[int, PSCustomObject]]::new()) [void]$FileDict.TryAdd($Item.NodeIndex, [PSCustomObject]@{ Aphorism = $Aphorism }) [void]$GenBag.Add($Item.NodeId) } else { Write-Warning "$($Item.NodeId): AI returned empty response" [void]$FailBag.Add($Item.NodeId) } } catch { Write-Warning "$($Item.NodeId): $($_.Exception.Message)" [void]$FailBag.Add($Item.NodeId) } $Done = [System.Threading.Interlocked]::Increment($CompRef) $Pct = [math]::Min(100, [math]::Round(($Done / $TotalCount) * 100)) Write-Progress -Id $ProgId -Activity 'Generating aphorisms' -Status "$Done / $TotalCount" -PercentComplete $Pct } -ThrottleLimit $Concurrency } Write-Progress -Id $ProgressId -Activity 'Generating aphorisms' -Completed # Write-back stage — one file at a time, deep-copies node property tree so # additive graph_attributes doesn't collide with strict-mode parsing. foreach ($FilePath in $Results.Keys) { $FileResults = $Results[$FilePath] if ($FileResults.Count -eq 0) { continue } $TaxData = Get-Content $FilePath -Raw | ConvertFrom-Json $NodesArray = @($TaxData.nodes) foreach ($Idx in $FileResults.Keys) { $Entry = $FileResults[$Idx] $Node = $NodesArray[$Idx] if (-not $Node.PSObject.Properties['graph_attributes'] -or $null -eq $Node.graph_attributes) { $Node | Add-Member -NotePropertyName 'graph_attributes' -NotePropertyValue ([PSCustomObject]@{}) -Force } if ($Node.graph_attributes.PSObject.Properties['aphorism']) { $Node.graph_attributes.aphorism = $Entry.Aphorism } else { $Node.graph_attributes | Add-Member -NotePropertyName 'aphorism' -NotePropertyValue $Entry.Aphorism } } if ($PSCmdlet.ShouldProcess($FilePath, 'Save aphorisms')) { $TaxData | ConvertTo-Json -Depth 20 | Set-Content -Path $FilePath -Encoding UTF8 $FileName = Split-Path $FilePath -Leaf Write-Verbose "Updated $($FileResults.Count) nodes in $FileName" } } $GenCount = @($Generated).Count $FailCount = @($Failed).Count $SkipCount = $SkippedExisting + $SkippedPillar + $SkippedDeprecated + $SkippedEmpty + $SkippedByIdFilter # ── Exact-dup audit (t/1550#4 CL note 2) ───────────────────────────── # Flags aphorisms that came out bit-identical across nodes so CL can # audit and re-run those specific IDs with -Force. Logged not # regenerated — deterministic-seed retry would just recollide. $Duplicates = [System.Collections.Generic.List[PSObject]]::new() $bucket = @{} foreach ($FilePath in $Results.Keys) { $FileResults = $Results[$FilePath] foreach ($Idx in $FileResults.Keys) { $Entry = $FileResults[$Idx] $key = $Entry.Aphorism.Trim().ToLowerInvariant() if ($bucket.ContainsKey($key)) { $Duplicates.Add([PSCustomObject]@{ Aphorism = $Entry.Aphorism Nodes = @($bucket[$key], "$FilePath::$Idx") }) } else { $bucket[$key] = "$FilePath::$Idx" } } } if ($Duplicates.Count -gt 0) { Write-Warning "$($Duplicates.Count) exact-duplicate aphorism(s) detected — see .Duplicates on the return object; re-run affected IDs with -Force to regenerate" } Write-Host "" Write-Host "Done. Generated: $GenCount | Skipped: $SkipCount | Failed: $FailCount | Duplicates: $($Duplicates.Count)" [PSCustomObject]@{ Generated = $GenCount Skipped = $SkipCount Failed = $FailCount Duplicates = @($Duplicates) } } function New-NodeAphorism { <# .SYNOPSIS Fail-open aphorism generator for a single node (t/1550 write-path wiring). .DESCRIPTION Called from Invoke-ProposalApply on node CREATE / label-or-description UPDATE, and from Repair-PovDescriptions after a description rewrite. Generation failure NEVER blocks the underlying node write — enrichment is never a gate on taxonomy operations. On failure this warns and returns $null; caller should leave the aphorism field untouched. Skips pillar nodes and situations by contract — callers passing a pillar node get $null back, matching the batch backfill's exclusion. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Node, [Parameter()] [string]$Pov, [Parameter()] [string]$Reason = 'write-path-regen' ) Set-StrictMode -Version Latest if (-not $Node -or -not $Node.PSObject.Properties['id']) { return $null } $NodeId = [string]$Node.id # Situations out of scope v1. if ($NodeId -match '^(sit|cc)-') { return $null } $Desc = if ($Node.PSObject.Properties['description']) { [string]$Node.description } else { '' } if ([string]::IsNullOrWhiteSpace($Desc) -or $Desc.Length -lt 20) { return $null } if ($Desc.StartsWith('A thematic pillar')) { return $null } $Label = if ($Node.PSObject.Properties['label']) { [string]$Node.label } else { '' } $Category = if ($Node.PSObject.Properties['category']) { [string]$Node.category } else { '' } # Deprecation marker may live on either label or description (CL review t/1550#4). if ($Desc.StartsWith('[DEPRECATED]') -or $Label.StartsWith('[DEPRECATED]')) { return $null } try { $Rendered = Get-Prompt -Name 'pov-aphorism' -Replacements @{ pov = $Pov category = $Category label = $Label description = $Desc } $AIResult = Invoke-AIByUsage -UsageId 'enrichment.pov-aphorism' -Values @{ prompt = $Rendered } if ($null -eq $AIResult -or [string]::IsNullOrWhiteSpace($AIResult.Text)) { return $null } return $AIResult.Text.Trim().Trim('"').Trim() } catch { Write-Warning "New-NodeAphorism failed for $NodeId ($Reason): $($_.Exception.Message)" return $null } } function Set-NodeAphorism { <# .SYNOPSIS Writes an aphorism to node.graph_attributes.aphorism if generation succeeded (t/1550). .DESCRIPTION Convenience wrapper used by the write-path wiring: calls New-NodeAphorism, and only mutates the node if a non-empty aphorism came back. Silently leaves the node untouched on failure — matches the fail-open contract required by the ticket ("generation failure must not block the underlying node write"). #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Node, [Parameter()] [string]$Pov, [Parameter()] [string]$Reason = 'write-path-regen' ) Set-StrictMode -Version Latest $Aphorism = New-NodeAphorism -Node $Node -Pov $Pov -Reason $Reason if ([string]::IsNullOrWhiteSpace($Aphorism)) { return } if (-not $Node.PSObject.Properties['graph_attributes'] -or $null -eq $Node.graph_attributes) { $Node | Add-Member -NotePropertyName 'graph_attributes' -NotePropertyValue ([PSCustomObject]@{}) -Force } if ($Node.graph_attributes.PSObject.Properties['aphorism']) { $Node.graph_attributes.aphorism = $Aphorism } else { $Node.graph_attributes | Add-Member -NotePropertyName 'aphorism' -NotePropertyValue $Aphorism } } |