Public/Invoke-TaxonomyProposal.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Invoke-TaxonomyProposal { <# .SYNOPSIS Uses AI to generate structured taxonomy improvement proposals based on health data. .DESCRIPTION Feeds taxonomy health metrics (orphan nodes, unmapped concepts, stance variance, coverage imbalances) to an AI model which returns structured NEW/SPLIT/MERGE/RELABEL proposals in JSON format. Proposals are written to taxonomy/proposals/proposal-{timestamp}.json. .PARAMETER Model AI model to use. Defaults to env default or 'gemini-3.1-flash-lite-preview'. .PARAMETER ApiKey AI API key. If omitted, resolved via backend-specific env var or AI_API_KEY. .PARAMETER Temperature Sampling temperature (0.0-1.0). Default: 0.3 (slightly creative). .PARAMETER RepoRoot Path to the repository root. Defaults to the module-resolved repo root. .PARAMETER DryRun Build and display the prompt preview, but do NOT call the API or write files. .PARAMETER OutputFile Path for the proposal JSON. Defaults to taxonomy/proposals/proposal-{timestamp}.json. .PARAMETER HealthData Pre-computed health data hashtable from Get-TaxonomyHealth -PassThru. If omitted, health data is computed fresh. .EXAMPLE Invoke-TaxonomyProposal -DryRun .EXAMPLE Invoke-TaxonomyProposal -Model 'gemini-2.5-flash' .EXAMPLE $h = Get-TaxonomyHealth -PassThru Invoke-TaxonomyProposal -HealthData $h #> [CmdletBinding(SupportsShouldProcess)] param( [ValidateScript({ Test-AIModelId $_ })] [ArgumentCompleter({ param($cmd, $param, $word) $script:ValidModelIds | Where-Object { $_ -like "$word*" } })] [string]$Model = 'gemini-3.1-flash-lite-preview', [string]$ApiKey = '', [ValidateRange(0.0, 1.0)] [double]$Temperature = 0.3, [string]$RepoRoot = $script:RepoRoot, [switch]$DryRun, [switch]$IncludeHarvestQueue, [string]$OutputFile = '', [hashtable]$HealthData = $null ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── 1. Validate environment ──────────────────────────────────────────────── Write-Step "Validating environment" if (-not (Test-Path $RepoRoot)) { Write-Fail "Repo root not found: $RepoRoot" throw "Repo root not found: $RepoRoot" } if (-not $DryRun) { if ($Model -match '^gemini') { $Backend = 'gemini' } elseif ($Model -match '^claude') { $Backend = 'claude' } elseif ($Model -match '^groq') { $Backend = 'groq' } elseif ($Model -match '^openai') { $Backend = 'openai' } else { $Backend = 'gemini' } $ResolvedKey = Resolve-AIApiKey -ExplicitKey $ApiKey -Backend $Backend if ([string]::IsNullOrWhiteSpace($ResolvedKey)) { $EnvHint = switch ($Backend) { 'gemini' { 'GEMINI_API_KEY' } 'claude' { 'ANTHROPIC_API_KEY' } 'groq' { 'GROQ_API_KEY' } 'openai' { 'OPENAI_API_KEY' } default { 'AI_API_KEY' } } Write-Fail "No API key found for $Backend backend." Write-Info "Set $EnvHint or AI_API_KEY, or pass -ApiKey." throw "No API key found for $Backend backend." } $ApiKey = $ResolvedKey } Write-OK "Model : $Model" Write-OK "Temperature : $Temperature" if ($DryRun) { Write-Warn "DRY RUN — no API call, no file writes" } # ── 2. Compute or accept health data ─────────────────────────────────────── Write-Step "Preparing health data" if ($HealthData) { Write-OK "Using pre-computed health data ($($HealthData.SummaryCount) summaries)" } else { $HealthData = Get-TaxonomyHealthData -RepoRoot $RepoRoot Write-OK "Computed fresh health data ($($HealthData.SummaryCount) summaries)" } # ── 3. Build compact data representations ────────────────────────────────── Write-Step "Building prompt context" # Taxonomy nodes (compact: id, label, description — gives the LLM enough to judge overlap) $CompactNodes = @() foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic', 'situations')) { $Entry = $script:TaxonomyData[$PovKey] if (-not $Entry) { continue } foreach ($Node in $Entry.nodes) { $Desc = '' if ($Node.PSObject.Properties['description']) { $Desc = $Node.description } $CompactNodes += @{ id = $Node.id label = $Node.label description = $Desc } } } $TaxonomyNodesJson = $CompactNodes | ConvertTo-Json -Depth 5 -Compress # Unmapped concepts (freq >= 2, or top 30) — compact with nearest-node similarity $NearestNodeMap = $HealthData.NearestNodeMap $NodeIndex = @{} foreach ($N in $CompactNodes) { $NodeIndex[$N.id] = $N } $BuildUnmapped = { param($Item) $Entry = @{ concept = $Item.Concept frequency = $Item.Frequency suggested_pov = $Item.SuggestedPov suggested_category = $Item.SuggestedCategory doc_count = $Item.ContributingDocs.Count } # Attach nearest existing nodes (by description embedding similarity) if ($NearestNodeMap -and $NearestNodeMap.ContainsKey($Item.NormalizedKey)) { $Entry.nearest_nodes = @($NearestNodeMap[$Item.NormalizedKey] | ForEach-Object { $NodeLabel = '' if ($NodeIndex.ContainsKey($_.NodeId)) { $NodeLabel = $NodeIndex[$_.NodeId].label } @{ id = $_.NodeId; similarity = $_.Similarity; label = $NodeLabel } }) } $Entry } $UnmappedForPrompt = @($HealthData.UnmappedConcepts | Where-Object { $_.Frequency -ge 2 } | ForEach-Object { & $BuildUnmapped $_ }) if ($UnmappedForPrompt.Count -eq 0) { $UnmappedForPrompt = @($HealthData.UnmappedConcepts | Select-Object -First 30 | ForEach-Object { & $BuildUnmapped $_ }) } $UnmappedJson = $UnmappedForPrompt | ConvertTo-Json -Depth 5 -Compress # Citation stats: orphans (capped at 50), most-cited (top 10), high-variance $CitationStats = @{ orphan_count = $HealthData.OrphanNodes.Count orphan_nodes = @($HealthData.OrphanNodes | Select-Object -First 50 | ForEach-Object { @{ id = $_.Id; label = $_.Label } }) most_cited = @($HealthData.MostCited | Select-Object -First 10 | ForEach-Object { @{ id = $_.Id; label = $_.Label; citations = $_.Citations } }) high_variance = @($HealthData.HighVarianceNodes | ForEach-Object { @{ id = $_.Id; label = $_.Label; total_stances = $_.TotalStances } }) } $CitationStatsJson = $CitationStats | ConvertTo-Json -Depth 5 -Compress # Coverage balance $CoverageBalanceJson = $HealthData.CoverageBalance | ConvertTo-Json -Depth 10 -Compress Write-OK "Compact nodes : $($CompactNodes.Count)" Write-OK "Unmapped for prompt : $($UnmappedForPrompt.Count)" Write-OK "Orphan nodes : $($CitationStats.orphan_nodes.Count)" Write-OK "High-variance nodes : $($CitationStats.high_variance.Count)" # ── 3b. Load vocabulary/dictionary ──────────────────────────────────────── $DictDir = Join-Path (Get-DataRoot) 'dictionary' $StandardizedJson = '[]' $ColloquialJson = '[]' if (Test-Path $DictDir) { $StdDir = Join-Path $DictDir 'standardized' $ColDir = Join-Path $DictDir 'colloquial' if (Test-Path $StdDir) { $StdTerms = @(Get-ChildItem -Path $StdDir -Filter '*.json' | ForEach-Object { $T = Get-Content $_.FullName -Raw | ConvertFrom-Json @{ canonical_form = $T.canonical_form display_form = $T.display_form definition = $T.definition primary_camp = $T.primary_camp_origin used_by_nodes = if ($T.PSObject.Properties['used_by_nodes']) { @($T.used_by_nodes) } else { @() } do_not_confuse = if ($T.PSObject.Properties['do_not_confuse_with']) { @($T.do_not_confuse_with | ForEach-Object { "$($_.term): $($_.note)" }) } else { @() } } }) $StandardizedJson = $StdTerms | ConvertTo-Json -Depth 5 -Compress Write-OK "Standardized terms : $($StdTerms.Count)" } if (Test-Path $ColDir) { $ColTerms = @(Get-ChildItem -Path $ColDir -Filter '*.json' | ForEach-Object { $T = Get-Content $_.FullName -Raw | ConvertFrom-Json @{ colloquial_term = $T.colloquial_term status = $T.status resolves_to = if ($T.PSObject.Properties['resolves_to']) { @($T.resolves_to | ForEach-Object { "$($_.standardized_term) ($($_.default_for_camp))" }) } else { @() } } }) $ColloquialJson = $ColTerms | ConvertTo-Json -Depth 5 -Compress Write-OK "Colloquial terms : $($ColTerms.Count)" } } else { Write-Warn "Dictionary not found at $DictDir — vocabulary constraints will be omitted" } # ── 4. Load prompt template ──────────────────────────────────────────────── $SystemPrompt = Get-Prompt -Name 'taxonomy-proposal' -Replacements @{ TAXONOMY_VERSION = $HealthData.TaxonomyVersion SUMMARY_COUNT = $HealthData.SummaryCount.ToString() } # ── 5. Assemble full prompt ──────────────────────────────────────────────── $FullPrompt = @" $SystemPrompt === HEALTH DATA === --- EXISTING TAXONOMY NODES --- $TaxonomyNodesJson --- UNMAPPED CONCEPTS (sorted by frequency) --- $UnmappedJson --- CITATION STATISTICS (orphans, most-cited, high-variance) --- $CitationStatsJson --- COVERAGE BALANCE (nodes per POV per category) --- $CoverageBalanceJson --- VOCABULARY (STANDARDIZED TERMS) --- These are the project's controlled vocabulary terms. Each has a canonical_form (machine ID used in node vocabulary_terms arrays), a display_form (human-readable), a definition, and the primary camp that coined it. Proposals MUST use these terms instead of bare colloquial forms. $StandardizedJson --- VOCABULARY (COLLOQUIAL TERMS — DO NOT USE BARE) --- These colloquial terms are ambiguous across camps. Each resolves to different standardized terms depending on context. Never use these bare in descriptions or labels — always use the camp-appropriate standardized form. $ColloquialJson "@ # Inject harvest queue if requested if ($IncludeHarvestQueue) { $HarvestQueuePath = Join-Path (Get-DataRoot) 'harvest-queue.json' if (Test-Path $HarvestQueuePath) { $QueueData = Get-Content $HarvestQueuePath -Raw | ConvertFrom-Json $QueuedItems = @($QueueData.items | Where-Object { $_.status -eq 'queued' }) if ($QueuedItems.Count -gt 0) { $QueueBlock = ($QueuedItems | ForEach-Object { "- $($_.label) ($($_.suggested_pov)/$($_.suggested_category)): $($_.description)" }) -join "`n" $FullPrompt += @" --- DEBATE-SOURCED CONCEPT CANDIDATES --- The following concepts were identified in structured debates and queued for consideration. Treat them as additional unmapped concept candidates alongside the health data above. $QueueBlock "@ Write-Info " Included $($QueuedItems.Count) harvest queue items" } } } $PromptLength = $FullPrompt.Length $EstTokens = [int]($PromptLength / 4) Write-OK "Prompt assembled: $PromptLength chars (~$EstTokens tokens est.)" # ── 6. DRY RUN — print and return ───────────────────────────────────────── if ($DryRun) { Write-Host "`n$('─' * 72)" -ForegroundColor DarkGray Write-Host " DRY RUN: PROMPT PREVIEW" -ForegroundColor Yellow Write-Host "$('─' * 72)" -ForegroundColor DarkGray Write-Host "`n[SYSTEM PROMPT — first 800 chars]" -ForegroundColor Cyan Write-Host $SystemPrompt.Substring(0, [Math]::Min(800, $SystemPrompt.Length)) -ForegroundColor Gray Write-Host "... (truncated for display)" -ForegroundColor DarkGray Write-Host "`n[TAXONOMY NODES — $($CompactNodes.Count) nodes, first 400 chars]" -ForegroundColor Cyan Write-Host $TaxonomyNodesJson.Substring(0, [Math]::Min(400, $TaxonomyNodesJson.Length)) -ForegroundColor Gray Write-Host "..." -ForegroundColor DarkGray Write-Host "`n[UNMAPPED CONCEPTS — $($UnmappedForPrompt.Count) entries]" -ForegroundColor Cyan $UnmappedPreview = $UnmappedJson.Substring(0, [Math]::Min(400, $UnmappedJson.Length)) Write-Host $UnmappedPreview -ForegroundColor Gray Write-Host "..." -ForegroundColor DarkGray Write-Host "`n[CITATION STATISTICS]" -ForegroundColor Cyan Write-Host $CitationStatsJson.Substring(0, [Math]::Min(400, $CitationStatsJson.Length)) -ForegroundColor Gray Write-Host "..." -ForegroundColor DarkGray Write-Host "`n[COVERAGE BALANCE]" -ForegroundColor Cyan Write-Host $CoverageBalanceJson -ForegroundColor Gray Write-Host "`n[VOCABULARY — standardized terms, first 400 chars]" -ForegroundColor Cyan Write-Host $StandardizedJson.Substring(0, [Math]::Min(400, $StandardizedJson.Length)) -ForegroundColor Gray Write-Host "..." -ForegroundColor DarkGray Write-Host "`n[VOCABULARY — colloquial terms]" -ForegroundColor Cyan Write-Host $ColloquialJson.Substring(0, [Math]::Min(400, $ColloquialJson.Length)) -ForegroundColor Gray Write-Host "..." -ForegroundColor DarkGray Write-Host "`n$('─' * 72)" -ForegroundColor DarkGray Write-Host " DRY RUN complete. No API call made. No files written." -ForegroundColor Yellow Write-Host "$('─' * 72)`n" -ForegroundColor DarkGray return } # ── 7. Call Invoke-AIApi ─────────────────────────────────────────────────── Write-Step "Calling AI API ($Model)" $StartTime = Get-Date Write-Info "Sending request..." $AiResult = Invoke-AIApi ` -Prompt $FullPrompt ` -Model $Model ` -ApiKey $ApiKey ` -Temperature $Temperature ` -MaxTokens 65536 ` -JsonMode ` -TimeoutSec 600 if ($null -eq $AiResult) { throw "AI API call returned null" } $Elapsed = (Get-Date) - $StartTime Write-OK "Response received from $($AiResult.Backend) in $([int]$Elapsed.TotalSeconds)s" # ── 8. Parse and validate response ───────────────────────────────────────── Write-Step "Parsing AI response" $RawText = $AiResult.Text $CleanedText = $RawText -replace '(?s)^```json\s*', '' -replace '(?s)\s*```$', '' $CleanedText = $CleanedText.Trim() try { $ProposalObject = $CleanedText | ConvertFrom-Json Write-OK "Valid JSON received" } catch { Write-Warn "JSON parse failed — attempting repair" $Repaired = Repair-TruncatedJson -Text $RawText if ($Repaired) { try { $ProposalObject = $Repaired | ConvertFrom-Json Write-OK "JSON repaired successfully" } catch { $ProposalObject = $null } } if ($null -eq $ProposalObject) { $DebugPath = Join-Path (Join-Path (Join-Path $RepoRoot 'taxonomy') 'proposals') "proposal-debug-$(Get-Date -Format 'yyyyMMdd-HHmmss').txt" $ProposalsDir = Join-Path (Join-Path $RepoRoot 'taxonomy') 'proposals' if (-not (Test-Path $ProposalsDir)) { New-Item -ItemType Directory -Path $ProposalsDir -Force | Out-Null } Write-Utf8NoBom -Path $DebugPath -Value $RawText Write-Fail "AI returned invalid JSON. Raw response saved: $DebugPath" throw "AI returned invalid JSON for taxonomy proposal" } } # Validate presence of proposals array if (-not $ProposalObject.proposals) { Write-Warn "Response missing 'proposals' array — may be empty or malformed" $ProposalObject | Add-Member -NotePropertyName 'proposals' -NotePropertyValue @() -ErrorAction SilentlyContinue } # ── Gap 9.1: Schema validation per proposal type ────────────────────────── $ValidActions = @('NEW','SPLIT','MERGE','RELABEL','REORDER','DEPTH_EXPAND','WIDTH_EXPAND') $ValidPovs = @('accelerationist','safetyist','skeptic','situations') $ValidCats = @('Desires','Beliefs','Intentions') $ValidatedProposals = [System.Collections.Generic.List[object]]::new() foreach ($P in @($ProposalObject.proposals)) { $ActionType = if ($P.PSObject.Properties['action']) { $P.action.ToUpperInvariant() } else { $null } $Errors = [System.Collections.Generic.List[string]]::new() if (-not $ActionType -or $ActionType -notin $ValidActions) { $Errors.Add("invalid or missing action type '$($P.action)'") } if (-not $P.PSObject.Properties['pov'] -or $P.pov -notin $ValidPovs) { $Errors.Add("invalid or missing pov '$($P.pov)'") } if ($P.pov -ne 'situations' -and (-not $P.PSObject.Properties['category'] -or $P.category -notin $ValidCats)) { if ($ActionType -notin @('MERGE','REORDER')) { $Errors.Add("invalid or missing category '$($P.category)' for non-situations node") } } if (-not $P.PSObject.Properties['label'] -or [string]::IsNullOrWhiteSpace($P.label)) { if ($ActionType -notin @('MERGE','REORDER')) { $Errors.Add("missing label") } } if (-not $P.PSObject.Properties['rationale'] -or [string]::IsNullOrWhiteSpace($P.rationale)) { $Errors.Add("missing rationale") } switch ($ActionType) { 'NEW' { if (-not $P.PSObject.Properties['suggested_id'] -or [string]::IsNullOrWhiteSpace($P.suggested_id)) { $Errors.Add("NEW requires suggested_id") } } 'SPLIT' { if (-not $P.PSObject.Properties['target_node_id'] -or [string]::IsNullOrWhiteSpace($P.target_node_id)) { $Errors.Add("SPLIT requires target_node_id") } if (-not $P.PSObject.Properties['children'] -or @($P.children).Count -lt 2) { $Errors.Add("SPLIT requires at least 2 children") } } 'MERGE' { if (-not $P.PSObject.Properties['merge_node_ids'] -or @($P.merge_node_ids).Count -lt 2) { $Errors.Add("MERGE requires merge_node_ids with at least 2 IDs") } if (-not $P.PSObject.Properties['surviving_node_id'] -or [string]::IsNullOrWhiteSpace($P.surviving_node_id)) { $Errors.Add("MERGE requires surviving_node_id") } } 'RELABEL' { if (-not $P.PSObject.Properties['target_node_id'] -or [string]::IsNullOrWhiteSpace($P.target_node_id)) { $Errors.Add("RELABEL requires target_node_id") } } 'REORDER' { if (-not $P.PSObject.Properties['target_node_id'] -or [string]::IsNullOrWhiteSpace($P.target_node_id)) { $Errors.Add("REORDER requires target_node_id") } if (-not $P.PSObject.Properties['new_parent_id'] -or [string]::IsNullOrWhiteSpace($P.new_parent_id)) { $Errors.Add("REORDER requires new_parent_id") } } 'DEPTH_EXPAND' { if (-not $P.PSObject.Properties['target_node_id'] -or [string]::IsNullOrWhiteSpace($P.target_node_id)) { $Errors.Add("DEPTH_EXPAND requires target_node_id") } if (-not $P.PSObject.Properties['children'] -or @($P.children).Count -lt 2) { $Errors.Add("DEPTH_EXPAND requires at least 2 children") } } 'WIDTH_EXPAND' { if (-not $P.PSObject.Properties['suggested_id'] -or [string]::IsNullOrWhiteSpace($P.suggested_id)) { $Errors.Add("WIDTH_EXPAND requires suggested_id") } } } $PLabel = if ($P.PSObject.Properties['label'] -and $P.label) { $P.label.Substring(0, [Math]::Min(40, $P.label.Length)) } else { '(no label)' } if ($Errors.Count -gt 0) { Write-Warn "Proposal '$PLabel' ($ActionType) rejected: $($Errors -join '; ')" } else { $ValidatedProposals.Add($P) } } $RejectedCount = @($ProposalObject.proposals).Count - $ValidatedProposals.Count if ($RejectedCount -gt 0) { Write-Warn "$RejectedCount proposal(s) rejected by schema validation" } # ── Gap 9.2: Duplicate proposal detection against existing proposals ─────── $ExistingProposals = [System.Collections.Generic.List[object]]::new() $ProposalsDir = Join-Path (Join-Path $RepoRoot 'taxonomy') 'proposals' if (Test-Path $ProposalsDir) { foreach ($ExFile in (Get-ChildItem -Path $ProposalsDir -Filter 'proposal-*.json' -File)) { try { $ExData = Get-Content $ExFile.FullName -Raw | ConvertFrom-Json if ($ExData.proposals) { foreach ($ep in $ExData.proposals) { $ExistingProposals.Add($ep) } } } catch { } } } if ($ExistingProposals.Count -gt 0) { $DedupedProposals = [System.Collections.Generic.List[object]]::new() foreach ($P in $ValidatedProposals) { $IsDup = $false $ActionType = $P.action.ToUpperInvariant() foreach ($Existing in $ExistingProposals) { $ExAction = if ($Existing.PSObject.Properties['action']) { $Existing.action.ToUpperInvariant() } else { '' } if ($ExAction -ne $ActionType) { continue } switch ($ActionType) { 'MERGE' { if ($P.PSObject.Properties['merge_node_ids'] -and $Existing.PSObject.Properties['merge_node_ids']) { $NewSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($P.merge_node_ids)) $ExSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Existing.merge_node_ids)) $Overlap = [System.Collections.Generic.HashSet[string]]::new($NewSet) $Overlap.IntersectWith($ExSet) $Union = [System.Collections.Generic.HashSet[string]]::new($NewSet) $Union.UnionWith($ExSet) if ($Union.Count -gt 0 -and ($Overlap.Count / $Union.Count) -ge 0.5) { $IsDup = $true } } } 'NEW' { if ($P.PSObject.Properties['suggested_id'] -and $Existing.PSObject.Properties['suggested_id'] -and $P.suggested_id -eq $Existing.suggested_id) { $IsDup = $true } elseif ($P.PSObject.Properties['label'] -and $Existing.PSObject.Properties['label']) { $NewWords = [System.Collections.Generic.HashSet[string]]::new( [string[]]($P.label.ToLowerInvariant() -split '\s+'), [System.StringComparer]::OrdinalIgnoreCase ) $ExWords = [System.Collections.Generic.HashSet[string]]::new( [string[]]($Existing.label.ToLowerInvariant() -split '\s+'), [System.StringComparer]::OrdinalIgnoreCase ) $Isect = [System.Collections.Generic.HashSet[string]]::new($NewWords) $Isect.IntersectWith($ExWords) $Un = [System.Collections.Generic.HashSet[string]]::new($NewWords) $Un.UnionWith($ExWords) if ($Un.Count -gt 0 -and ($Isect.Count / $Un.Count) -ge 0.7) { $IsDup = $true } } } 'SPLIT' { if ($P.PSObject.Properties['target_node_id'] -and $Existing.PSObject.Properties['target_node_id'] -and $P.target_node_id -eq $Existing.target_node_id) { $IsDup = $true } } 'RELABEL' { if ($P.PSObject.Properties['target_node_id'] -and $Existing.PSObject.Properties['target_node_id'] -and $P.target_node_id -eq $Existing.target_node_id) { $IsDup = $true } } } if ($IsDup) { break } } $PLabel = if ($P.label) { $P.label.Substring(0, [Math]::Min(40, $P.label.Length)) } else { '(no label)' } if ($IsDup) { Write-Warn "Duplicate proposal skipped: [$ActionType] $PLabel" } else { $DedupedProposals.Add($P) } } $DupCount = $ValidatedProposals.Count - $DedupedProposals.Count if ($DupCount -gt 0) { Write-Warn "$DupCount proposal(s) removed as duplicates of existing proposals" } $ValidatedProposals = $DedupedProposals } $ProposalObject.proposals = @($ValidatedProposals) $ProposalCount = $ValidatedProposals.Count Write-OK "$ProposalCount proposal(s) after validation" # ── 9. Write proposal file ───────────────────────────────────────────────── Write-Step "Writing proposal file" $ProposalsDir = Join-Path (Join-Path $RepoRoot 'taxonomy') 'proposals' if (-not (Test-Path $ProposalsDir)) { New-Item -ItemType Directory -Path $ProposalsDir -Force | Out-Null } $Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' if (-not $OutputFile) { $OutputFile = Join-Path $ProposalsDir "proposal-$Timestamp.json" } # Enrich with metadata $FinalProposal = [ordered]@{ generated_at = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') model = $Model taxonomy_version = $HealthData.TaxonomyVersion summary_count = $HealthData.SummaryCount proposals = $ProposalObject.proposals } $ProposalJson = $FinalProposal | ConvertTo-Json -Depth 20 try { Write-Utf8NoBom -Path $OutputFile -Value $ProposalJson Write-OK "Proposal written to: $OutputFile" } catch { Write-Fail "Failed to write proposal file — $($_.Exception.Message)" Write-Info "Proposal data was generated but NOT saved. Check path and permissions." throw } # ── 10. Print human-readable summary ─────────────────────────────────────── Write-Host "`n$('═' * 72)" -ForegroundColor Cyan Write-Host " TAXONOMY PROPOSALS" -ForegroundColor White Write-Host " Model: $Model | Taxonomy v$($HealthData.TaxonomyVersion) | $ProposalCount proposal(s)" -ForegroundColor Gray Write-Host "$('═' * 72)" -ForegroundColor Cyan $ActionTypes = @('NEW', 'SPLIT', 'MERGE', 'RELABEL') foreach ($Action in $ActionTypes) { $Group = @($ProposalObject.proposals | Where-Object { $_.action -eq $Action }) if ($Group.Count -eq 0) { continue } $ActionColor = switch ($Action) { 'NEW' { 'Green' } 'SPLIT' { 'Cyan' } 'MERGE' { 'Yellow' } 'RELABEL' { 'Magenta' } } Write-Host "`n [$Action] ($($Group.Count))" -ForegroundColor $ActionColor foreach ($P in $Group) { if ($P.suggested_id) { $IdStr = "[$($P.suggested_id)]" } else { $IdStr = '' } if ($P.target_node_id) { $TargetStr = " (target: $($P.target_node_id))" } else { $TargetStr = '' } Write-Host " $IdStr $($P.label)$TargetStr" -ForegroundColor White Write-Host " POV: $($P.pov) | Category: $($P.category)" -ForegroundColor Gray if ($P.rationale) { if ($P.rationale.Length -gt 120) { $RatSnippet = $P.rationale.Substring(0, 120) + '...' } else { $RatSnippet = $P.rationale } Write-Host " Rationale: $RatSnippet" -ForegroundColor DarkGray } if ($P.PSObject.Properties['children'] -and $P.children.Count -gt 0) { Write-Host " Children:" -ForegroundColor Gray foreach ($Child in $P.children) { Write-Host " [$($Child.suggested_id)] $($Child.label)" -ForegroundColor Gray } } if ($P.PSObject.Properties['merge_node_ids'] -and $P.merge_node_ids.Count -gt 0) { Write-Host " Merging: $($P.merge_node_ids -join ', ') → $($P.surviving_node_id)" -ForegroundColor Gray } } } Write-Host "`n$('═' * 72)" -ForegroundColor Cyan Write-Host " Output: $OutputFile" -ForegroundColor Green Write-Host "$('═' * 72)`n" -ForegroundColor Cyan } |