Public/Repair-PovDescriptions.ps1

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

function Repair-PovDescriptions {
    <#
    .SYNOPSIS
        Detects and repairs POV node description issues: wrong genus, missing
        Encompasses/Excludes clauses, and truncated sentences.
    .DESCRIPTION
        Scans POV taxonomy files for 4 issue types:
          1. Wrong genus prefix (deterministic fix — no AI)
          2. Missing Encompasses clause (AI-assisted)
          3. Missing Excludes clause (AI-assisted)
          4. Truncated mid-sentence (AI-assisted completion)
 
        Uses -WhatIf to preview changes. Deterministic fixes (wrong genus) are
        applied without AI calls. AI fixes use temperature 0.2 for repair fidelity.
    .PARAMETER POV
        Filter to a specific POV file.
    .PARAMETER Category
        Filter to a specific BDI category.
    .PARAMETER Model
        AI model for description repair. Default: gemini-3.1-flash-lite-preview.
    .PARAMETER ApiKey
        AI API key. Resolved from env if omitted.
    .EXAMPLE
        Repair-PovDescriptions -WhatIf
    .EXAMPLE
        Repair-PovDescriptions -POV safetyist
    .EXAMPLE
        Repair-PovDescriptions -Category Beliefs -Model gemini-2.5-flash
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ValidateSet('accelerationist', 'safetyist', 'skeptic')]
        [string]$POV,

        [ValidateSet('Beliefs', 'Desires', 'Intentions')]
        [string]$Category,

        [ValidateScript({ Test-AIModelId $_ })]
        [ArgumentCompleter({ param($cmd, $param, $word) $script:ValidModelIds | Where-Object { $_ -like "$word*" } })]
        [string]$Model = 'gemini-3.1-flash-lite-preview',

        [string]$ApiKey
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $TaxDir = Get-TaxonomyDir

    # Resolve API key for AI fixes
    $ResolvedKey = $null
    if (-not $WhatIfPreference) {
        if ($Model -match '^gemini') { $Backend = 'gemini' }
        elseif ($Model -match '^claude') { $Backend = 'claude' }
        elseif ($Model -match '^openai') { $Backend = 'openai' }
        else { $Backend = 'gemini' }
        $ResolvedKey = Resolve-AIApiKey -ExplicitKey $ApiKey -Backend $Backend
    }

    $PovFiles = @('accelerationist', 'safetyist', 'skeptic')
    if ($POV) { $PovFiles = @($POV) }

    $GenusMap = @{
        Beliefs    = 'A Belief within'
        Desires    = 'A Desire within'
        Intentions = 'An Intention within'
    }

    $TotalFixed = 0
    $TotalSkipped = 0
    $IssuesByType = @{ wrong_genus = 0; missing_encompasses = 0; missing_excludes = 0; truncated = 0 }

    foreach ($PovName in $PovFiles) {
        $FilePath = Join-Path $TaxDir "$PovName.json"
        if (-not (Test-Path $FilePath)) { continue }

        $Data = Get-Content $FilePath -Raw | ConvertFrom-Json
        $Modified = $false

        Write-Host "`n=== $PovName ===" -ForegroundColor Cyan

        foreach ($Node in $Data.nodes) {
            $Desc = $Node.description
            if (-not $Desc) { continue }
            # Skip parent nodes
            if ($Node.PSObject.Properties['children'] -and $Node.children -and @($Node.children).Count -gt 0) { continue }
            # Category filter
            if ($Category -and $Node.category -ne $Category) { continue }

            $NodeCat = $Node.category
            $Issues = [System.Collections.Generic.List[string]]::new()
            $NewDesc = $Desc

            # ── Issue 1: Wrong genus ──────────────────────────────────────
            if ($Desc -notmatch '^An?\s+(Belief|Desire|Intention)\s+within') {
                $Issues.Add('wrong_genus')
                $IssuesByType.wrong_genus++

                if ($NodeCat -and $GenusMap.ContainsKey($NodeCat)) {
                    $Prefix = "$($GenusMap[$NodeCat]) $PovName discourse that"
                    # Try to salvage the existing text after the bad prefix
                    if ($NewDesc -match '^An?\s+\w+\s+(?:within\s+\w+\s+discourse\s+that\s+|to\s+|that\s+|where\s+|for\s+)(.+)') {
                        $NewDesc = "$Prefix $($Matches[1])"
                    }
                    elseif ($NewDesc -match '^(?:The\s+process\s+of|A\s+(?:strategy|method|practice|policy\s+approach|situation\s+concept)\s+(?:where|that|for|of)\s*)(.+)') {
                        $NewDesc = "$Prefix describes $($Matches[1])"
                    }
                    else {
                        # Wrap entirely
                        $NewDesc = "$Prefix describes the following: $NewDesc"
                    }
                }
            }

            # ── Issue 2: Truncated sentence ───────────────────────────────
            $Trimmed = $NewDesc.TrimEnd()
            $LastChar = $Trimmed[-1]
            $EndsOk = $Trimmed -match '[.!?]["\x27\)\u201D]?\s*$'
            if ($LastChar -match '[a-zA-Z]' -and -not $EndsOk) {
                $Issues.Add('truncated')
                $IssuesByType.truncated++
            }

            # ── Issue 3/4: Missing Encompasses / Excludes ─────────────────
            if ($NewDesc -notmatch 'Encompasses:') {
                $Issues.Add('missing_encompasses')
                $IssuesByType.missing_encompasses++
            }
            if ($NewDesc -notmatch 'Excludes:') {
                $Issues.Add('missing_excludes')
                $IssuesByType.missing_excludes++
            }

            if ($Issues.Count -eq 0) { continue }

            # ── Determine if AI is needed ─────────────────────────────────
            $NeedsAI = $Issues.Contains('truncated') -or $Issues.Contains('missing_encompasses') -or $Issues.Contains('missing_excludes')
            $IsGenusOnly = $Issues.Count -eq 1 -and $Issues[0] -eq 'wrong_genus'

            # ── Show before ───────────────────────────────────────────────
            $BeforeTail = if ($Desc.Length -gt 60) { "...$($Desc.Substring($Desc.Length - 60))" } else { $Desc }

            if ($NeedsAI -and -not $WhatIfPreference -and $ResolvedKey) {
                # AI-assisted repair
                $Prompt = @"
You are repairing a taxonomy node description. Do NOT rewrite — only fix the specific issues listed.
 
Node: $($Node.id)
Label: $($Node.label)
Category: $NodeCat
POV: $PovName
 
Current description:
$NewDesc
 
Issues to fix:
$(($Issues | ForEach-Object { "- $_" }) -join "`n")
 
Rules:
1. If the description is truncated mid-sentence, complete ONLY the broken sentence (1 clause).
2. If missing Encompasses: clause, add it on a new line. Format: "Encompasses: [2-4 concrete sub-themes]."
3. If missing Excludes: clause, add it on a new line. Format: "Excludes: [what neighboring concepts cover instead]."
4. The description MUST use genus-differentia structure with 3 lines separated by \n:
   Line 1: "$($GenusMap[$NodeCat] ?? 'A Belief within') $PovName discourse that [differentia]."
   Line 2: "Encompasses: [sub-themes]."
   Line 3: "Excludes: [boundaries]."
5. Preserve existing text verbatim where it is correct — only add missing parts.
6. Return ONLY the corrected description text. No JSON, no markdown, no explanation.
"@


                try {
                    $Result = Invoke-AIApi -Prompt $Prompt -Model $Model -ApiKey $ResolvedKey `
                        -Temperature 0.2 -MaxTokens 1024 -TimeoutSec 30
                    if ($Result -and $Result.Text) {
                        $NewDesc = $Result.Text.Trim() -replace '^\s*```\s*', '' -replace '\s*```\s*$', ''
                    }
                }
                catch {
                    Write-Warning " AI repair failed for $($Node.id): $($_.Exception.Message)"
                    $TotalSkipped++
                    continue
                }
            }

            $AfterTail = if ($NewDesc.Length -gt 60) { "...$($NewDesc.Substring($NewDesc.Length - 60))" } else { $NewDesc }

            # ── Apply or preview ──────────────────────────────────────────
            $IssueLabel = $Issues -join ', '
            if ($PSCmdlet.ShouldProcess("$($Node.id) [$IssueLabel]", 'Repair description')) {
                $Node.description = $NewDesc
                $Modified = $true
                $TotalFixed++
                Write-Host " FIXED $($Node.id) [$IssueLabel]" -ForegroundColor Green
                Write-Host " Before: $BeforeTail" -ForegroundColor DarkGray
                Write-Host " After: $AfterTail" -ForegroundColor Gray
            }
            else {
                Write-Host " WOULD FIX $($Node.id) [$IssueLabel]" -ForegroundColor Yellow
                Write-Host " Tail: $BeforeTail" -ForegroundColor DarkGray
                if ($NeedsAI) {
                    Write-Host " Fix: (AI will complete — requires API call)" -ForegroundColor DarkYellow
                } else {
                    Write-Host " After: $AfterTail" -ForegroundColor Gray
                }
            }
        }

        # Write back if modified
        if ($Modified -and -not $WhatIfPreference) {
            $Data | ConvertTo-Json -Depth 20 | Set-Content -Path $FilePath -Encoding UTF8
            Write-Host " Saved $FilePath" -ForegroundColor Green
        }
    }

    Write-Host "`n=== SUMMARY ===" -ForegroundColor Cyan
    Write-Host " Issues found:"
    $IssuesByType.GetEnumerator() | Sort-Object Name | ForEach-Object { Write-Host " $($_.Key): $($_.Value)" }
    Write-Host " Fixed: $TotalFixed"
    Write-Host " Skipped: $TotalSkipped"
}