Public/Invoke-VernacularBatch.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

<#
.SYNOPSIS
    Batch-generates plain-language descriptions for taxonomy nodes.
.DESCRIPTION
    Reads taxonomy JSON files and generates plain_description for each node
    using an AI model. Skips nodes that already have an up-to-date plain
    description (unless -Force), deprecated nodes, and nodes with empty or
    very short descriptions.
.PARAMETER TaxonomyPath
    Path to the taxonomy directory containing POV JSON files.
    Defaults to Get-TaxonomyDir.
.PARAMETER Model
    AI model to use. Default: gemini-3.5-flash-lite.
.PARAMETER Version
    Version tag written to plain_description_version for staleness detection.
    Default: flash-lite:v1.
.PARAMETER Concurrency
    Number of parallel AI calls. Default: 10.
.PARAMETER Force
    Regenerate all nodes, even those with up-to-date plain_description.
.PARAMETER Id
    Process only the specified node ID(s). Can be a single ID or a list.
.EXAMPLE
    Invoke-VernacularBatch
.EXAMPLE
    Invoke-VernacularBatch -Force -Concurrency 5
.LINK
    Show-AITriadHelp
.LINK
    Invoke-AIByUsage
.LINK
    Invoke-BDIWeightAssignment
.LINK
    Invoke-EdgeWeightEvaluation
.LINK
    Invoke-AphorismBatch
.LINK
    New-SyntheticCorpus
#>

function Invoke-VernacularBatch {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()][string]$TaxonomyPath,
        [Parameter()][string]$Model = 'gemini-3.5-flash-lite',
        [Parameter()][string]$Version = 'flash-lite:v1',
        [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 plain-language node descriptions' `
            -Problem "Taxonomy directory not found: $TaxonomyPath" `
            -Location 'Invoke-VernacularBatch' `
            -NextSteps @('Check that ai-triad-data is available', 'Set $env:AI_TRIAD_DATA_ROOT or verify .aitriad.json'))
    }

    $TaxFiles = @('accelerationist.json', 'safetyist.json', 'skeptic.json', 'situations.json')

    $SystemPrompt = @'
You rewrite academic ontological descriptions into plain language that a high school student could understand.
 
Rules:
1. Write at a 10th-grade reading level (Flesch-Kincaid grade ~10).
2. Use as many sentences as needed to faithfully convey the full meaning. Aim for 40-150 words — shorter for simple ideas, longer for complex multi-part claims. Never pad, but never truncate a meaningful distinction either.
3. Drop the "A Belief/Desire/Intention within X discourse that..." opener — start directly with the idea.
4. Convert "Encompasses:" items into natural prose — weave them into the explanation rather than dropping them. These sub-concepts are important.
5. Drop the "Excludes:" clause — it's an ontological boundary marker, not part of the idea itself.
6. Replace jargon with everyday words (e.g., "telemetry" → "monitoring", "post-scarcity" → "a world without shortages").
7. Preserve the core claim and its important nuances — do not add, soften, or editorialize.
8. Use active voice when possible.
9. Return ONLY the rewritten text — no labels, no explanation.
'@


    $NodesToProcess = [System.Collections.Generic.List[PSCustomObject]]::new()
    $SkippedExisting = 0
    $SkippedDeprecated = 0
    $SkippedEmpty = 0
    $SkippedByIdFilter = 0 # New counter

    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" # Added verbose

        $TaxData = Get-Content $FilePath -Raw | ConvertFrom-Json
        $NodeIndex = 0
        foreach ($Node in @($TaxData.nodes)) {

            # New: Filter by Id
            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 }

            if ([string]::IsNullOrWhiteSpace($Desc)) {
                $SkippedEmpty++
                Write-Verbose "Skipping $($Node.id): Description is empty or whitespace." # Added verbose
                $NodeIndex++
                continue
            }
            if ($Desc.Length -lt 20) {
                $SkippedEmpty++
                Write-Verbose "Skipping $($Node.id): Description is too short." # Added verbose
                $NodeIndex++
                continue
            }
            if ($Desc.StartsWith('[DEPRECATED]')) {
                $SkippedDeprecated++
                Write-Verbose "Skipping $($Node.id): Node is deprecated." # Added verbose
                $NodeIndex++
                continue
            }

            if (-not $Force) {
                $HasPlain = $Node.PSObject.Properties['plain_description'] -and
                            -not [string]::IsNullOrWhiteSpace($Node.plain_description)
                $VersionMatch = $Node.PSObject.Properties['plain_description_version'] -and
                                $Node.plain_description_version -eq $Version
                if ($HasPlain -and $VersionMatch) {
                    $SkippedExisting++
                    Write-Verbose "Skipping $($Node.id): Plain description is up-to-date." # Added verbose
                    $NodeIndex++
                    continue
                }
            }

            $NodesToProcess.Add([PSCustomObject]@{
                File      = $FileName
                FilePath  = $FilePath
                NodeId    = $Node.id
                NodeIndex = $NodeIndex
                Desc      = $Desc
            })
            $NodeIndex++
        }
    }

    $Total = $NodesToProcess.Count
    Write-Verbose "Nodes to process after filtering: $Total" # Changed to verbose
    Write-Verbose "Skipped (up-to-date): $SkippedExisting" # Changed to verbose
    Write-Verbose "Skipped (deprecated): $SkippedDeprecated" # Changed to verbose
    Write-Verbose "Skipped (empty/short): $SkippedEmpty" # Changed to verbose
    Write-Verbose "Skipped (by ID filter): $SkippedByIdFilter" # New verbose output
    Write-Verbose "" # Changed to verbose

    if ($Total -eq 0) {
        Write-Host 'Nothing to generate — all nodes are up-to-date or filtered out.' # Updated message
        return [PSCustomObject]@{ Generated = 0; Skipped = $SkippedExisting + $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 plain descriptions' -Status "0 / $Total" -PercentComplete 0

    $NodesToProcess | ForEach-Object -Parallel {
        Import-Module $using:ModulePath -Force -WarningAction SilentlyContinue
        Import-Module $using:EnrichPath -Force -WarningAction SilentlyContinue
        $Item        = $_
        $SysPrompt   = $using:SystemPrompt
        $ModelName   = $using:Model
        $VersionTag  = $using:Version
        $GenBag      = $using:Generated
        $FailBag     = $using:Failed
        $ResultsDict = $using:Results
        $CompRef     = $using:Completed
        $TotalCount  = $using:Total
        $ProgId      = $using:ProgressId

        $UserPrompt = "Rewrite this node description:`n`n$($Item.Desc)"

        Write-Verbose "Processing node $($Item.NodeId)" # Added verbose inside parallel loop

        try {
            $AIResult = Invoke-AIApi -Prompt $UserPrompt -SystemInstruction $SysPrompt `
                -Model $ModelName -Temperature 0.2 -MaxTokens 400

            if ($null -ne $AIResult -and -not [string]::IsNullOrWhiteSpace($AIResult.Text)) {
                $PlainText = $AIResult.Text.Trim()

                $FileDict = $ResultsDict.GetOrAdd($Item.FilePath,
                    [System.Collections.Concurrent.ConcurrentDictionary[int, PSCustomObject]]::new())
                [void]$FileDict.TryAdd($Item.NodeIndex, [PSCustomObject]@{
                    PlainDescription = $PlainText
                    Version          = $VersionTag
                })
                [void]$GenBag.Add($Item.NodeId)
                Write-Verbose "Generated plain description for $($Item.NodeId)" # Added verbose
            } else {
                Write-Warning "$($Item.NodeId): AI returned empty response"
                [void]$FailBag.Add($Item.NodeId)
                Write-Verbose "Failed to generate plain description for $($Item.NodeId): AI returned empty response." # Added verbose
            }
        } catch {
            Write-Warning "$($Item.NodeId): $($_.Exception.Message)"
            [void]$FailBag.Add($Item.NodeId)
            Write-Verbose "Failed to generate plain description for $($Item.NodeId): $($_.Exception.Message)." # Added verbose
        }

        $Done = [System.Threading.Interlocked]::Increment($CompRef)
        $Pct  = [math]::Min(100, [math]::Round(($Done / $TotalCount) * 100))
        Write-Progress -Id $ProgId -Activity 'Generating plain descriptions' -Status "$Done / $TotalCount" -PercentComplete $Pct

    } -ThrottleLimit $Concurrency

    Write-Progress -Id $ProgressId -Activity 'Generating plain descriptions' -Completed

    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 ($Node.PSObject.Properties['plain_description']) {
                $Node.plain_description = $Entry.PlainDescription
            } else {
                $Node | Add-Member -NotePropertyName 'plain_description' -NotePropertyValue $Entry.PlainDescription
            }

            if ($Node.PSObject.Properties['plain_description_version']) {
                $Node.plain_description_version = $Entry.Version
            } else {
                $Node | Add-Member -NotePropertyName 'plain_description_version' -NotePropertyValue $Entry.Version
            }
        }

        $TaxData | ConvertTo-Json -Depth 20 | Set-Content -Path $FilePath -Encoding UTF8
        $FileName = Split-Path $FilePath -Leaf
        Write-Verbose "Updated $($FileResults.Count) nodes in $FileName" # Changed to verbose
    }

    $GenCount  = @($Generated).Count
    $FailCount = @($Failed).Count
    $SkipCount = $SkippedExisting + $SkippedDeprecated + $SkippedEmpty + $SkippedByIdFilter # Updated skip count

    Write-Host ""
    Write-Host "Done. Generated: $GenCount | Skipped: $SkipCount | Failed: $FailCount"

    [PSCustomObject]@{
        Generated = $GenCount
        Skipped   = $SkipCount
        Failed    = $FailCount
    }
}