Private/Merge-CruxQuestionForm.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. <# .SYNOPSIS Preserve/generate the canonical question_form field on aggregated cruxes (t/1507, t/1509). .DESCRIPTION Merges the question_form field onto each aggregated crux: 1. Preserve — if the crux id exists in the previous OutputPath file, carry its question_form forward. 2. Generate — for new cruxes, call the enrichment.crux-question-form UsageID, strip code fences, parse JSON, and validate (non-empty, single question, ends with '?', <=45 words). Failures leave the field absent so consumers fall back to statement — no invalid text is ever written. Mutates the crux dictionaries in place. Returns a stats object. #> function Merge-CruxQuestionForm { [CmdletBinding()] param( # Aggregated cruxes list — each element is an [ordered] hashtable with id / type / statement. [Parameter(Mandatory)] [object]$Cruxes, # Previous output file to preserve question_form from. Missing file = no preservation. [Parameter(Mandatory)] [string]$PreviousPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Build map: id -> @{ Statement (Trim'd); QuestionForm }. Preservation requires # BOTH id AND statement to match (t/1509 CL review) — otherwise the dedup # clustering may have re-assigned the id to a materially different crux, and # riding the old question_form along would be a silent semantic clobber. $ExistingQF = @{} if (Test-Path $PreviousPath) { try { $Existing = Get-Content -Raw $PreviousPath | ConvertFrom-Json if ($Existing.PSObject.Properties['cruxes'] -and $Existing.cruxes) { foreach ($EC in @($Existing.cruxes)) { if (-not $EC.PSObject.Properties['id']) { continue } if (-not $EC.PSObject.Properties['question_form']) { continue } if (-not $EC.PSObject.Properties['statement']) { continue } $Qf = [string]$EC.question_form if ([string]::IsNullOrWhiteSpace($Qf)) { continue } $ExistingQF[[string]$EC.id] = [PSCustomObject]@{ Statement = ([string]$EC.statement).Trim() QuestionForm = $Qf.Trim() } } } } catch { Write-Warning "Merge-CruxQuestionForm: could not read $PreviousPath for preservation: $($_.Exception.Message)" } } $Preserved = 0; $Generated = 0; $Failed = 0 foreach ($Crux in $Cruxes) { $CId = [string]$Crux.id if ($ExistingQF.ContainsKey($CId)) { $Prev = $ExistingQF[$CId] $CurStmt = ([string]$Crux.statement).Trim() if ($Prev.Statement -ceq $CurStmt) { $Crux['question_form'] = $Prev.QuestionForm $Preserved++ continue } Write-Verbose "Merge-CruxQuestionForm: id ${CId} statement changed — falling through to regeneration" } $Stmt = [string]$Crux.statement if ([string]::IsNullOrWhiteSpace($Stmt)) { $Failed++; continue } $Type = if ($Crux.Contains('type')) { [string]$Crux.type } else { 'empirical' } try { $Res = Invoke-AIByUsage -UsageId 'enrichment.crux-question-form' -Values @{ type = $Type statement = $Stmt } if (-not $Res -or -not $Res.PSObject.Properties['Text']) { throw 'no Text field on Invoke-AIByUsage result (call failed)' } $Txt = [string]$Res.Text -replace '(?s)^\s*```(?:json)?\s*','' -replace '(?s)\s*```\s*$','' $Parsed = $Txt | ConvertFrom-Json $Q = if ($Parsed.PSObject.Properties['question']) { [string]$Parsed.question } else { '' } if (Test-CruxQuestionForm -Question $Q) { $Crux['question_form'] = $Q.Trim() $Generated++ } else { Write-Verbose "Merge-CruxQuestionForm: validation failed for $CId (raw: $Q)" $Failed++ } } catch { Write-Verbose "Merge-CruxQuestionForm: generation failed for ${CId}: $($_.Exception.Message)" $Failed++ } } [PSCustomObject]@{ Preserved = $Preserved Generated = $Generated Failed = $Failed } } <# .SYNOPSIS Validate a candidate question_form string per t/1507 rules. .DESCRIPTION Returns $true iff the string is non-empty, ends with '?', is a single question (only one '?' after trim), and is <=45 words (35 target, 45 tolerance). Mirrors research/comp-linguist/_cruxq_run.ps1 Test-ValidQuestion so the two call sites stay consistent. #> function Test-CruxQuestionForm { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string]$Question ) if ([string]::IsNullOrWhiteSpace($Question)) { return $false } $Q = $Question.Trim() if (-not $Q.EndsWith('?')) { return $false } if (@($Q -split '\s+').Count -gt 45) { return $false } if (@($Q -split '\?').Count -gt 2) { return $false } return $true } |