Public/Invoke-OrgStanceExtraction.ps1

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

function Invoke-OrgStanceExtraction {
    <#
    .SYNOPSIS
        Stage 1 of t/1553 — extracts an org's own asserted/opposed positions
        from each approved PUBLISHED document.
    .DESCRIPTION
        Consumes approved PUBLISHED edges (org → source) from the
        organization_edges.json store. For each (org, source) pair whose
        target document exists in the ingested corpus, loads snapshot.md
        and calls the `enrichment.org-stance-extraction` UsageID.
 
        Claims come back as structured JSON:
          { text, canonical_proposition, polarity, extraction_confidence }
        keyed to (org_id, source_id). Persisted to
        organization_stance_claims.json under the taxonomy origin, with
        an idempotence guard on (org_id, source_id) — re-runs skip
        already-extracted pairs unless -Force.
 
        Edges whose target isn't on disk (historical curated edges without
        an ingested doc) are skipped with a count in the summary — never
        an AI call.
 
        Zero writes to organization_edges.json — Stage 1 only produces
        claims, not proposals. Proposal emission is Stage 3.
    .PARAMETER Org
        Restrict to specific org id(s). Default: all orgs with at least
        one approved PUBLISHED edge.
    .PARAMETER MaxDocs
        Safety cap on documents processed per run. Default: no cap.
        Use `-MaxDocs 3` for CL first-batch review.
    .PARAMETER Concurrency
        Parallel AI calls. Default 3 — conservative to stay under
        flash-lite rate limits (same lesson as t/1550 aphorism backfill).
    .PARAMETER Force
        Re-extract for (org, source) pairs that already have claims.
    .PARAMETER MaxDocChars
        Truncate document body to this many chars before extraction
        (flash-lite context safety). Default 20000 (~5K tokens).
    .PARAMETER OutputPath
        Override the claim file path (data-repo taxonomy/Origin by default).
    .EXAMPLE
        Invoke-OrgStanceExtraction -MaxDocs 3
    .EXAMPLE
        Invoke-OrgStanceExtraction -Org org-001,org-002
    .LINK
        Show-AITriadHelp
    .LINK
        Invoke-OrgClaimMatching
    .LINK
        Invoke-OrgDerivedCampScores
    .LINK
        Invoke-OrgPublishedSeeding
    .LINK
        Get-Organization
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()]
        [string[]]$Org,

        [Parameter()]
        [ValidateRange(1, 500)]
        [int]$MaxDocs,

        [Parameter()]
        [ValidateRange(1, 20)]
        [int]$Concurrency = 3,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [ValidateRange(500, 200000)]
        [int]$MaxDocChars = 20000,

        [Parameter()]
        [Alias('Path')]
        [string]$OutputPath
    )

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

    # ── Resolve paths + registries ───────────────────────────────────────
    $sourcesRoot = Get-SourcesDir
    if (-not (Test-Path $sourcesRoot)) {
        throw (New-ActionableError `
            -Goal 'Extract org stance claims' `
            -Problem "Sources directory not found: $sourcesRoot" `
            -Location 'Invoke-OrgStanceExtraction' `
            -NextSteps @('Set $env:AI_TRIAD_SOURCES_ROOT', 'Verify .aitriad.json'))
    }

    if (-not $OutputPath) {
        $OutputPath = Join-Path (Get-TaxonomyDir) 'organization_stance_claims.json'
    }

    $orgStore   = Get-OrganizationsStore
    $edgeStore  = Get-OrganizationEdgesStore
    $orgs       = if ($orgStore.PSObject.Properties['organizations']) { @($orgStore.organizations) } else { @() }
    $edges      = if ($edgeStore -and $edgeStore.PSObject.Properties['edges']) { @($edgeStore.edges) } else { @() }

    $orgById = @{}
    foreach ($o in $orgs) { $orgById[[string]$o.id] = $o }

    # ── Build work list ──────────────────────────────────────────────────
    $wanted = @()
    if ($Org) { $wanted = @($Org) }
    $wantedCount = @($wanted).Count
    $approved = @($edges | Where-Object {
        $_.PSObject.Properties['type']   -and
        $_.PSObject.Properties['status'] -and
        $_.PSObject.Properties['source'] -and
        $_.PSObject.Properties['target'] -and
        [string]$_.type -eq 'PUBLISHED' -and
        [string]$_.status -eq 'approved' -and
        [string]$_.target -notlike 'org-*' -and
        (($wantedCount -eq 0) -or ([string]$_.source -in $wanted))
    })

    # ── Load existing claims (idempotence guard) ─────────────────────────
    $existingClaims = [System.Collections.Generic.List[PSObject]]::new()
    $covered = @{}   # "$orgId::$sourceId" → 1
    if (Test-Path $OutputPath) {
        $prev = Get-Content $OutputPath -Raw | ConvertFrom-Json
        if ($prev.PSObject.Properties['claims']) {
            foreach ($c in @($prev.claims)) {
                $existingClaims.Add($c)
                if ($c.PSObject.Properties['org_id'] -and $c.PSObject.Properties['source_id']) {
                    $covered["$($c.org_id)::$($c.source_id)"] = 1
                }
            }
        }
    }

    $workItems = [System.Collections.Generic.List[PSObject]]::new()
    $skippedNoDoc     = 0
    $skippedAlreadyDone = 0
    $skippedNoOrg      = 0

    foreach ($e in $approved) {
        $orgId    = [string]$e.source
        $sourceId = [string]$e.target
        $key      = "$orgId::$sourceId"

        if (-not $Force -and $covered.ContainsKey($key)) {
            $skippedAlreadyDone++
            continue
        }

        if (-not $orgById.ContainsKey($orgId)) {
            $skippedNoOrg++
            continue
        }

        $sourceDir = Join-Path $sourcesRoot $sourceId
        $snapshot  = Join-Path $sourceDir 'snapshot.md'
        if (-not (Test-Path $snapshot)) {
            $skippedNoDoc++
            continue
        }

        $org   = $orgById[$orgId]
        $orgName = if ($org.PSObject.Properties['name']) { [string]$org.name } else { $orgId }
        $orgType = if ($org.PSObject.Properties['type']) { [string]$org.type } else { 'organization' }

        $workItems.Add([PSCustomObject]@{
            OrgId    = $orgId
            OrgName  = $orgName
            OrgType  = $orgType
            SourceId = $sourceId
            Snapshot = $snapshot
        })
    }

    if ($MaxDocs -and $workItems.Count -gt $MaxDocs) {
        $workItems = $workItems | Select-Object -First $MaxDocs
    }

    $Total = @($workItems).Count
    Write-Verbose "Work items: $Total (skipped no-doc: $skippedNoDoc, already-done: $skippedAlreadyDone, no-org: $skippedNoOrg)"

    if ($Total -eq 0) {
        Write-Host 'Nothing to extract — no approved PUBLISHED edges match the filter, or all are already done.'
        return [PSCustomObject]@{
            Extracted        = 0
            ClaimsWritten    = 0
            Failed           = 0
            SkippedNoDoc     = $skippedNoDoc
            SkippedAlreadyDone = $skippedAlreadyDone
            SkippedNoOrg     = $skippedNoOrg
            OutputPath       = $OutputPath
        }
    }

    # ── Pre-render prompts in the outer runspace (Get-Prompt is Private,
    # invisible in -Parallel workers — same fix as t/1550 aphorism). ────
    $moduleRoot = $script:ModuleRoot
    $promptSubs = [System.Collections.Generic.List[PSObject]]::new()
    foreach ($item in $workItems) {
        $docText = Get-Content $item.Snapshot -Raw
        if ($docText.Length -gt $MaxDocChars) { $docText = $docText.Substring(0, $MaxDocChars) }
        $rendered = Get-Prompt -Name 'org-stance-extraction' -Replacements @{
            org_name = $item.OrgName
            org_type = $item.OrgType
            document = $docText
        }
        $promptSubs.Add([PSCustomObject]@{
            OrgId     = $item.OrgId
            OrgName   = $item.OrgName
            OrgType   = $item.OrgType
            SourceId  = $item.SourceId
            Prompt    = $rendered
            DocChars  = $docText.Length
        })
    }

    if (-not $PSCmdlet.ShouldProcess("$Total document(s)", 'Extract stance claims via enrichment.org-stance-extraction')) {
        return [PSCustomObject]@{
            WouldProcess = $Total
            OutputPath   = $OutputPath
        }
    }

    # ── Extract (parallel) ───────────────────────────────────────────────
    $newClaims = [System.Collections.Concurrent.ConcurrentBag[PSObject]]::new()
    $failed    = [System.Collections.Concurrent.ConcurrentBag[string]]::new()
    $invalid   = [System.Collections.Concurrent.ConcurrentBag[string]]::new()
    # t/1553#14 — CL flagged that opposes-tagged claims which also contain
    # oppositional lexicon in the proposition (oppose/reject/resist/against/
    # "least promising") double-mark direction and invert stance downstream.
    # Lint surfaces recurrences; does NOT drop (CL's rule).
    $flagged   = [System.Collections.Concurrent.ConcurrentBag[PSObject]]::new()
    # Pattern covers direct opposition lexicon AND negated deontics
    # (t/1553#16 audit — "ought NOT to X" + polarity=opposes still inverts).
    $doubleMarkPattern = '(?i)(\b(oppose|reject|resist|against|least promising)\b|\b(ought|should|must|need|shall|may)\s+not\b)'

    # Validator scriptblock — passed through $using: to the parallel workers
    # (Test-OrgStanceClaim is a Private helper and isn't callable inside
    # -Parallel worker runspaces; same class of bug as Get-Prompt).
    $validator = {
        param($claim)
        if ($null -eq $claim) { return [PSCustomObject]@{ Ok = $false; Reason = 'null claim' } }
        if (-not $claim.PSObject.Properties['polarity']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing polarity' } }
        $pol = [string]$claim.polarity
        if ($pol -notin @('asserts', 'opposes')) { return [PSCustomObject]@{ Ok = $false; Reason = "polarity '$pol' not in {asserts, opposes}" } }
        if (-not $claim.PSObject.Properties['extraction_confidence']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing extraction_confidence' } }
        $conf = 0.0
        try { $conf = [double]$claim.extraction_confidence } catch { return [PSCustomObject]@{ Ok = $false; Reason = "extraction_confidence not numeric" } }
        if ($conf -lt 0.0 -or $conf -gt 1.0) { return [PSCustomObject]@{ Ok = $false; Reason = "extraction_confidence $conf out of [0,1]" } }
        if (-not $claim.PSObject.Properties['canonical_proposition']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing canonical_proposition' } }
        $prop = [string]$claim.canonical_proposition
        if ([string]::IsNullOrWhiteSpace($prop)) { return [PSCustomObject]@{ Ok = $false; Reason = 'canonical_proposition empty' } }
        $wc = @($prop -split '\s+' | Where-Object { $_ }).Count
        if ($wc -gt 60) { return [PSCustomObject]@{ Ok = $false; Reason = "canonical_proposition too long: $wc words (> 60)" } }
        if (-not $claim.PSObject.Properties['text']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing text' } }
        if ([string]::IsNullOrWhiteSpace([string]$claim.text)) { return [PSCustomObject]@{ Ok = $false; Reason = 'text empty' } }
        [PSCustomObject]@{ Ok = $true; Reason = $null }
    }
    $ModulePath = Join-Path $moduleRoot 'AITriad.psm1'
    $EnrichPath = Join-Path $moduleRoot '..' 'AIEnrich.psm1'
    if (-not (Test-Path $EnrichPath)) { $EnrichPath = Join-Path $moduleRoot 'AIEnrich.psm1' }
    $ProgressId = 1
    $Completed  = [ref]0

    Write-Progress -Id $ProgressId -Activity 'Extracting stance claims' -Status "0 / $Total" -PercentComplete 0

    if ($Concurrency -eq 1) {
        # Sequential path (deterministic, testable).
        foreach ($item in $promptSubs) {
            try {
                $ai = Invoke-AIByUsage -UsageId 'enrichment.org-stance-extraction' -Values @{
                    prompt = $item.Prompt
                }
                if ($null -ne $ai -and $ai.Text) {
                    # Defensive strip of any ```json ... ``` fences the model might add
                    # despite prompt "no markdown fences" instruction.
                    $body = [string]$ai.Text
                    $body = $body -replace '^\s*```(json)?\s*', ''
                    $body = $body -replace '\s*```\s*$', ''
                    $parsed = $body.Trim() | ConvertFrom-Json -ErrorAction Stop
                    foreach ($c in @($parsed.claims)) {
                        # Post-parse validation (t/1553#12 CL condition — schema no
                        # longer guards the boundary, so the cmdlet does).
                        $valid = & $validator $c
                        if (-not $valid.Ok) {
                            $invalid.Add("$($item.OrgId)::$($item.SourceId): $($valid.Reason)")
                            Write-Warning "Dropped claim for $($item.OrgId)::$($item.SourceId): $($valid.Reason)"
                            continue
                        }
                        # t/1553#14 double-mark lint (flag, not drop).
                        if ([string]$c.polarity -eq 'opposes' -and
                            ([string]$c.canonical_proposition) -match $doubleMarkPattern) {
                            $flagged.Add([PSCustomObject]@{
                                org_id                = $item.OrgId
                                source_id             = $item.SourceId
                                canonical_proposition = [string]$c.canonical_proposition
                                Reason                = 'double-mark: opposes-tagged proposition contains oppositional lexicon'
                            })
                        }
                        $newClaims.Add([PSCustomObject]@{
                            org_id                = $item.OrgId
                            source_id             = $item.SourceId
                            text                  = [string]$c.text
                            canonical_proposition = [string]$c.canonical_proposition
                            polarity              = [string]$c.polarity
                            extraction_confidence = [double]$c.extraction_confidence
                            extracted_at          = (Get-Date).ToString('o')
                            extraction_model      = if ($ai.PSObject.Properties['Model']) { $ai.Model } else { 'gemini-3.5-flash-lite' }
                        })
                    }
                } else {
                    $failed.Add("$($item.OrgId)::$($item.SourceId): empty AI response")
                }
            } catch {
                $failed.Add("$($item.OrgId)::$($item.SourceId): $($_.Exception.Message)")
                Write-Warning "$($item.OrgId)::$($item.SourceId): $($_.Exception.Message)"
            }
            $Done = [System.Threading.Interlocked]::Increment($Completed)
            $Pct  = [math]::Min(100, [math]::Round(($Done / $Total) * 100))
            Write-Progress -Id $ProgressId -Activity 'Extracting stance claims' -Status "$Done / $Total" -PercentComplete $Pct
        }
    } else {
        $promptSubs | ForEach-Object -Parallel {
            Import-Module $using:ModulePath -Force -WarningAction SilentlyContinue
            Import-Module $using:EnrichPath -Force -WarningAction SilentlyContinue
            $item      = $_
            $NewBag    = $using:newClaims
            $FailBag   = $using:failed
            $InvBag    = $using:invalid
            $FlagBag   = $using:flagged
            $DoubleMarkPattern = $using:doubleMarkPattern
            $CompRef   = $using:Completed
            $TotalCnt  = $using:Total
            $ProgId    = $using:ProgressId

            # Inline validator — ForEach-Object -Parallel forbids passing
            # scriptblocks via $using:, so we duplicate the logic here.
            $validator = {
                param($claim)
                if ($null -eq $claim) { return [PSCustomObject]@{ Ok = $false; Reason = 'null claim' } }
                if (-not $claim.PSObject.Properties['polarity']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing polarity' } }
                $pol = [string]$claim.polarity
                if ($pol -notin @('asserts', 'opposes')) { return [PSCustomObject]@{ Ok = $false; Reason = "polarity '$pol' not in {asserts, opposes}" } }
                if (-not $claim.PSObject.Properties['extraction_confidence']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing extraction_confidence' } }
                $conf = 0.0
                try { $conf = [double]$claim.extraction_confidence } catch { return [PSCustomObject]@{ Ok = $false; Reason = "extraction_confidence not numeric" } }
                if ($conf -lt 0.0 -or $conf -gt 1.0) { return [PSCustomObject]@{ Ok = $false; Reason = "extraction_confidence $conf out of [0,1]" } }
                if (-not $claim.PSObject.Properties['canonical_proposition']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing canonical_proposition' } }
                $prop = [string]$claim.canonical_proposition
                if ([string]::IsNullOrWhiteSpace($prop)) { return [PSCustomObject]@{ Ok = $false; Reason = 'canonical_proposition empty' } }
                $wc = @($prop -split '\s+' | Where-Object { $_ }).Count
                if ($wc -gt 60) { return [PSCustomObject]@{ Ok = $false; Reason = "canonical_proposition too long: $wc words (> 60)" } }
                if (-not $claim.PSObject.Properties['text']) { return [PSCustomObject]@{ Ok = $false; Reason = 'missing text' } }
                if ([string]::IsNullOrWhiteSpace([string]$claim.text)) { return [PSCustomObject]@{ Ok = $false; Reason = 'text empty' } }
                [PSCustomObject]@{ Ok = $true; Reason = $null }
            }

            try {
                $ai = Invoke-AIByUsage -UsageId 'enrichment.org-stance-extraction' -Values @{
                    prompt = $item.Prompt
                }
                if ($null -ne $ai -and $ai.Text) {
                    # Defensive strip of any ```json ... ``` fences the model might add
                    # despite prompt "no markdown fences" instruction.
                    $body = [string]$ai.Text
                    $body = $body -replace '^\s*```(json)?\s*', ''
                    $body = $body -replace '\s*```\s*$', ''
                    $parsed = $body.Trim() | ConvertFrom-Json -ErrorAction Stop
                    foreach ($c in @($parsed.claims)) {
                        $valid = & $validator $c
                        if (-not $valid.Ok) {
                            $InvBag.Add("$($item.OrgId)::$($item.SourceId): $($valid.Reason)")
                            Write-Warning "Dropped claim for $($item.OrgId)::$($item.SourceId): $($valid.Reason)"
                            continue
                        }
                        # t/1553#14 double-mark lint (flag, not drop).
                        if ([string]$c.polarity -eq 'opposes' -and
                            ([string]$c.canonical_proposition) -match $DoubleMarkPattern) {
                            $FlagBag.Add([PSCustomObject]@{
                                org_id                = $item.OrgId
                                source_id             = $item.SourceId
                                canonical_proposition = [string]$c.canonical_proposition
                                Reason                = 'double-mark: opposes-tagged proposition contains oppositional lexicon'
                            })
                        }
                        $NewBag.Add([PSCustomObject]@{
                            org_id                = $item.OrgId
                            source_id             = $item.SourceId
                            text                  = [string]$c.text
                            canonical_proposition = [string]$c.canonical_proposition
                            polarity              = [string]$c.polarity
                            extraction_confidence = [double]$c.extraction_confidence
                            extracted_at          = (Get-Date).ToString('o')
                            extraction_model      = if ($ai.PSObject.Properties['Model']) { $ai.Model } else { 'gemini-3.5-flash-lite' }
                        })
                    }
                } else {
                    $FailBag.Add("$($item.OrgId)::$($item.SourceId): empty AI response")
                }
            } catch {
                $FailBag.Add("$($item.OrgId)::$($item.SourceId): $($_.Exception.Message)")
                Write-Warning "$($item.OrgId)::$($item.SourceId): $($_.Exception.Message)"
            }

            $Done = [System.Threading.Interlocked]::Increment($CompRef)
            $Pct  = [math]::Min(100, [math]::Round(($Done / $TotalCnt) * 100))
            Write-Progress -Id $ProgId -Activity 'Extracting stance claims' -Status "$Done / $TotalCnt" -PercentComplete $Pct
        } -ThrottleLimit $Concurrency
    }

    Write-Progress -Id $ProgressId -Activity 'Extracting stance claims' -Completed

    # ── Merge + persist ──────────────────────────────────────────────────
    $writtenNew   = @($newClaims).Count
    if ($writtenNew -gt 0) {
        # -Force replaces old claims for the same (org, source); no-Force
        # can't reach here for existing (org, source) pairs anyway.
        if ($Force) {
            $refreshedKeys = @{}
            foreach ($item in $promptSubs) { $refreshedKeys["$($item.OrgId)::$($item.SourceId)"] = 1 }
            $keep = [System.Collections.Generic.List[PSObject]]::new()
            foreach ($c in $existingClaims) {
                $k = "$($c.org_id)::$($c.source_id)"
                if (-not $refreshedKeys.ContainsKey($k)) { $keep.Add($c) }
            }
            $existingClaims = $keep
        }
        foreach ($c in @($newClaims)) { $existingClaims.Add($c) }
    }

    $store = [PSCustomObject]@{
        _schema_version = '1.0.0'
        _doc            = 'Organization stance claims extracted per t/1553 Stage 1. Feeds Stage 2/3 claim→node matching. Machine-proposed / human-disposes downstream.'
        last_modified   = (Get-Date).ToString('yyyy-MM-dd')
        claim_count     = @($existingClaims).Count
        claims          = @($existingClaims)
    }

    if ($writtenNew -gt 0) {
        $temp = "$OutputPath.tmp"
        $json = $store | ConvertTo-Json -Depth 8
        Set-Content -Path $temp -Value $json -Encoding utf8NoBOM
        [System.IO.File]::Move($temp, $OutputPath, $true)
    }

    $failCount    = @($failed).Count
    $invalidCount = @($invalid).Count
    $flaggedCount = @($flagged).Count
    Write-Host ""
    Write-Host "Done. Documents processed: $Total | Claims extracted: $writtenNew | Invalid dropped: $invalidCount | Double-mark flagged: $flaggedCount | Failed: $failCount | Skipped no-doc: $skippedNoDoc | Skipped already-done: $skippedAlreadyDone"

    [PSCustomObject]@{
        Extracted          = $Total - $failCount
        ClaimsWritten      = $writtenNew
        InvalidDropped     = $invalidCount
        InvalidItems       = @($invalid)
        FlaggedDoubleMark  = $flaggedCount
        FlaggedItems       = @($flagged)
        Failed             = $failCount
        FailedItems        = @($failed)
        SkippedNoDoc       = $skippedNoDoc
        SkippedAlreadyDone = $skippedAlreadyDone
        SkippedNoOrg       = $skippedNoOrg
        OutputPath         = $OutputPath
    }
}