Public/Invoke-POVSummary.ps1

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

function Invoke-POVSummary {
    <#
    .SYNOPSIS
        Processes a single source document through AI to extract a structured
        POV summary mapped to the AI Triad taxonomy.
    .DESCRIPTION
        Implements the core AI summarization loop for ONE document:
            1. Loads sources/<doc-id>/snapshot.md
            2. Loads all four taxonomy files + TAXONOMY_VERSION
            3. Builds a structured prompt (system + taxonomy + document)
            4. Calls the AI API (Gemini, Claude, or Groq)
            5. Validates and writes summaries/<doc-id>.json
            6. Updates sources/<doc-id>/metadata.json (summary_status, summary_version)
            7. Runs basic conflict detection
    .PARAMETER DocId
        The document slug ID, e.g. "altman-2024-agi-path".
    .PARAMETER RepoRoot
        Path to the root of the ai-triad-research repository.
        Defaults to the module-resolved repo root.
    .PARAMETER ApiKey
        AI API key. If omitted, resolved via backend-specific env var or AI_API_KEY.
    .PARAMETER Model
        AI model to use. Defaults to "gemini-3.1-flash-lite-preview".
        Supports Gemini, Claude, and Groq backends.
    .PARAMETER Temperature
        Sampling temperature (0.0-1.0). Default: 0.1
    .PARAMETER DryRun
        Build and display the prompt, but do NOT call the API or write any files.
    .PARAMETER Force
        Re-process the document even if summary_status is already "current".
    .EXAMPLE
        Invoke-POVSummary -DocId "altman-2024-agi-path"
    .EXAMPLE
        Invoke-POVSummary -DocId "altman-2024-agi-path" -DryRun
    .EXAMPLE
        Invoke-POVSummary -DocId "lecun-2024-critique" -Model "gemini-2.5-flash-lite"
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0, HelpMessage = "Document slug ID, e.g. altman-2024-agi-path")]
        [string]$DocId,

        [string]$RepoRoot    = $script:RepoRoot,

        [string]$ApiKey      = '',

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

        [ValidateRange(0.0, 1.0)]
        [double]$Temperature = 0.1,

        [switch]$DryRun,
        [switch]$Force
    )

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

    # -- STEP 0 — Validate inputs and resolve paths ---------------------------
    Write-Step "Validating inputs"

    $paths = @{
        Root         = $RepoRoot
        TaxonomyDir  = Get-TaxonomyDir
        SourcesDir   = Get-SourcesDir
        SummariesDir = Get-SummariesDir
        ConflictsDir = Get-ConflictsDir
        VersionFile  = Get-VersionFile
        DocDir       = Join-Path (Get-SourcesDir) $DocId
        SnapshotFile = Join-Path (Get-SourcesDir) $DocId "snapshot.md"
        MetadataFile = Join-Path (Get-SourcesDir) $DocId "metadata.json"
        SummaryFile  = Join-Path (Get-SummariesDir) "$DocId.json"
    }

    if (-not (Test-Path $paths.Root)) {
        Write-Fail "Repo root not found: $($paths.Root)"
        throw "Repo root not found: $($paths.Root)"
    }

    if (-not (Test-Path $paths.DocDir)) {
        Write-Fail "Document folder not found: $($paths.DocDir)"
        Write-Info "Expected: sources/$DocId/"
        throw "Document folder not found: sources/$DocId/"
    }

    if (-not (Test-Path $paths.SnapshotFile)) {
        Write-Fail "snapshot.md not found: $($paths.SnapshotFile)"
        throw "snapshot.md not found for $DocId"
    }

    if (-not (Test-Path $paths.MetadataFile)) {
        Write-Fail "metadata.json not found: $($paths.MetadataFile)"
        throw "metadata.json not found for $DocId"
    }

    $metadata = Get-Content $paths.MetadataFile -Raw | ConvertFrom-Json
    if ((-not $Force) -and (-not $DryRun) -and ($metadata.summary_status -eq "current")) {
        Write-Warn "Summary is already current (taxonomy v$($metadata.summary_version))."
        Write-Info "Use -Force to re-process anyway."
        return
    }

    foreach ($dir in @($paths.SummariesDir, $paths.ConflictsDir)) {
        if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
    }

    if (-not $DryRun) {
        $Backend = if     ($Model -match '^gemini') { 'gemini' }
                   elseif ($Model -match '^claude') { 'claude' }
                   elseif ($Model -match '^groq')   { 'groq'   }
                   else                             { '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' }
                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 "Doc ID : $DocId"
    Write-OK "Repo root : $RepoRoot"
    Write-OK "Model : $Model"
    Write-OK "Temperature : $Temperature"
    if ($DryRun) { Write-Warn "DRY RUN — no API call, no file writes" }

    # -- STEP 1 — Load taxonomy version ---------------------------------------
    Write-Step "Loading taxonomy"

    if (-not (Test-Path $paths.VersionFile)) {
        Write-Fail "TAXONOMY_VERSION file not found at: $($paths.VersionFile)"
        throw "TAXONOMY_VERSION not found"
    }
    $taxonomyVersion = (Get-Content $paths.VersionFile -Raw).Trim()
    Write-OK "Taxonomy version: $taxonomyVersion"

    # -- STEP 2 — Load all four taxonomy files --------------------------------
    $taxonomyFiles   = @("accelerationist.json", "safetyist.json", "skeptic.json", "cross-cutting.json")
    $taxonomyContext = [ordered]@{}

    foreach ($file in $taxonomyFiles) {
        $filePath = Join-Path $paths.TaxonomyDir $file
        if (-not (Test-Path $filePath)) {
            Write-Fail "Taxonomy file missing: $filePath"
            throw "Taxonomy file missing: $file"
        }
        $taxonomyContext[$file] = Get-Content $filePath -Raw | ConvertFrom-Json
        $nodeCount = $taxonomyContext[$file].nodes.Count
        Write-OK " $file ($nodeCount nodes)"
    }

    $taxonomyJson = $taxonomyContext | ConvertTo-Json -Depth 20 -Compress:$false

    # -- STEP 2b — Load policy registry for prompt context ---------------------
    $policyRegistryContext = ''
    $policyRegistryPath = Join-Path $paths.TaxonomyDir 'policy_actions.json'
    if (Test-Path $policyRegistryPath) {
        $policyReg = Get-Content -Raw -Path $policyRegistryPath | ConvertFrom-Json
        if ($policyReg.policies -and $policyReg.policies.Count -gt 0) {
            # Build compact ID + action listing, one per line, cap at ~5KB
            $policyLines = $policyReg.policies | ForEach-Object { "$($_.id): $($_.action)" }
            $policyBlock = $policyLines -join "`n"
            if ($policyBlock.Length -gt 5000) {
                $policyBlock = $policyBlock.Substring(0, 5000) + "`n... (truncated)"
            }
            $policyRegistryContext = @"

=== POLICY REGISTRY (use pol-NNN IDs when referencing policy actions) ===
$policyBlock
"@

            Write-OK "Policy registry loaded: $($policyReg.policies.Count) policies for prompt context"
        }
    }

    # -- STEP 3 — Load the document snapshot ----------------------------------
    Write-Step "Loading document snapshot"

    $snapshotText    = Get-Content $paths.SnapshotFile -Raw
    $snapshotLength  = $snapshotText.Length
    $estimatedTokens = [int]($snapshotLength / 4)

    Write-OK "Snapshot loaded: $snapshotLength chars (~$estimatedTokens tokens estimated)"
    Write-Info "Title from metadata: $($metadata.title)"
    Write-Info "POV tags in metadata: $($metadata.pov_tags -join ', ')"

    if ($estimatedTokens -gt 100000) {
        Write-Warn "Document is very long (~$estimatedTokens tokens). Consider chunking if the API call fails."
    }

    # -- STEP 4 — Build the prompt --------------------------------------------
    Write-Step "Building prompt"

    # Density-scaled extraction guidance based on document size
    $wordCount = ($snapshotText -split '\s+').Count
    $floors = Get-DensityFloors -WordCount $wordCount
    $kpMin  = $floors.KpMin
    $kpMax  = [Math]::Max(8,  [int]($wordCount / 200))
    $fcMin  = $floors.FcMin
    $fcMax  = [Math]::Max(8,  [int]($wordCount / 300))
    $ucMin  = $floors.UcMin
    $ucMax  = [Math]::Max(5,  [int]($wordCount / 800))
    Write-Info "Document: ~$wordCount words → key_points $kpMin-$kpMax/camp, claims $fcMin-$fcMax, unmapped $ucMin-$ucMax"

    $outputSchema = Get-Prompt -Name 'pov-summary-schema'
    $systemPrompt = Get-Prompt -Name 'pov-summary-system' -Replacements @{
        WORD_COUNT = $wordCount
        KP_MIN     = $kpMin
        KP_MAX     = $kpMax
        FC_MIN     = $fcMin
        FC_MAX     = $fcMax
        UC_MIN     = $ucMin
        UC_MAX     = $ucMax
    }

    $fullPrompt = @"
$systemPrompt

=== TAXONOMY (version $taxonomyVersion) ===
$taxonomyJson
$policyRegistryContext

=== OUTPUT SCHEMA (your response must match this structure) ===
$outputSchema

=== DOCUMENT: $DocId ===
Title: $($metadata.title)
POV tags (pre-classified): $($metadata.pov_tags -join ', ')
Topic tags: $($metadata.topic_tags -join ', ')

--- DOCUMENT CONTENT ---
$snapshotText
"@


    $promptLength         = $fullPrompt.Length
    $promptTokensEstimate = [int]($promptLength / 4)
    Write-OK "Prompt assembled: $promptLength chars (~$promptTokensEstimate tokens estimated)"
    Write-Info " System instructions : $([int]($systemPrompt.Length / 4)) tokens (est.)"
    Write-Info " Taxonomy context : $([int]($taxonomyJson.Length / 4)) tokens (est.)"
    Write-Info " Document content : $estimatedTokens tokens (est.)"

    # -- DRY RUN — print and return -------------------------------------------
    if ($DryRun) {
        Write-Host "`n$('─' * 72)" -ForegroundColor DarkGray
        Write-Host " DRY RUN: FULL PROMPT PREVIEW" -ForegroundColor Yellow
        Write-Host "$('─' * 72)" -ForegroundColor DarkGray

        Write-Host "`n[SYSTEM PROMPT]" -ForegroundColor Cyan
        Write-Host $systemPrompt -ForegroundColor Gray

        Write-Host "`n[TAXONOMY CONTEXT — first 500 chars]" -ForegroundColor Cyan
        Write-Host $taxonomyJson.Substring(0, [Math]::Min(500, $taxonomyJson.Length)) -ForegroundColor Gray
        Write-Host "... (truncated for display)" -ForegroundColor DarkGray

        Write-Host "`n[DOCUMENT CONTENT — first 500 chars]" -ForegroundColor Cyan
        Write-Host $snapshotText.Substring(0, [Math]::Min(500, $snapshotText.Length)) -ForegroundColor Gray
        Write-Host "... (truncated for display)" -ForegroundColor DarkGray

        Write-Host "`n[OUTPUT SCHEMA]" -ForegroundColor Cyan
        Write-Host $outputSchema -ForegroundColor Gray

        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
    }

    # -- STEP 5 — Call the AI API (with density validation + retry) ------------
    Write-Step "Calling AI API ($Model)"

    $densityFloors = Get-DensityFloors -WordCount $wordCount
    $maxDensityRetries = 1
    $startTime = Get-Date
    $summaryObject = $null

    for ($densityAttempt = 0; $densityAttempt -le $maxDensityRetries; $densityAttempt++) {
        $attemptPrompt = $fullPrompt
        if ($densityAttempt -gt 0) {
            Write-Warn "Retry $densityAttempt/$maxDensityRetries — density too low"
            $attemptPrompt = $fullPrompt + "`n`n" + $densityRetryNudge
        }

        Write-Info "Sending request..."

        $aiResult = Invoke-AIApi `
            -Prompt      $attemptPrompt `
            -Model       $Model `
            -ApiKey      $ApiKey `
            -Temperature $Temperature `
            -MaxTokens   32768 `
            -JsonMode `
            -TimeoutSec  120

        if ($null -eq $aiResult) {
            throw "AI API call returned null for $DocId"
        }

        $elapsed = (Get-Date) - $startTime
        Write-OK "Response received from $($aiResult.Backend) in $([int]$elapsed.TotalSeconds)s"

        # -- STEP 6 — Extract and validate the response JSON ----------------------
        Write-Step "Parsing and validating AI response"

        $rawText = $aiResult.Text

        Write-Verbose "Raw AI response:"
        Write-Verbose $rawText

        $cleanedText = $rawText -replace '(?s)^```json\s*', '' -replace '(?s)\s*```$', ''
        $cleanedText = $cleanedText.Trim()

        try {
            $summaryObject = $cleanedText | ConvertFrom-Json -Depth 20
            Write-OK "Valid JSON received from $($aiResult.Backend)"
        } catch {
            # Attempt repair of truncated JSON
            Write-Warn "JSON parse failed — attempting repair"
            $repaired = Repair-TruncatedJson -Text $rawText
            if ($repaired) {
                try {
                    $summaryObject = $repaired | ConvertFrom-Json -Depth 20
                    Write-OK "JSON repaired successfully (truncated response recovered)"
                } catch {
                    $summaryObject = $null
                }
            }
            if ($null -eq $summaryObject) {
                Write-Fail "AI returned invalid JSON. Raw response saved for inspection."
                $debugPath = Join-Path $paths.SummariesDir "${DocId}.debug-raw.txt"
                Set-Content -Path $debugPath -Value $rawText -Encoding UTF8
                Write-Info "Raw response saved to: $debugPath"
                throw "AI returned invalid JSON for $DocId"
            }
        }

        $requiredKeys = @("pov_summaries", "factual_claims", "unmapped_concepts")
        $missingKeys  = $requiredKeys | Where-Object { $null -eq $summaryObject.PSObject.Properties[$_] }
        if ($missingKeys) {
            Write-Warn "Response is missing expected keys: $($missingKeys -join ', ')"
            Write-Info "Continuing with partial data — review the summary file manually."
        }

        $validStances = @("strongly_aligned","aligned","neutral","opposed","strongly_opposed","not_applicable")
        $camps = @("accelerationist","safetyist","skeptic")

        foreach ($camp in $camps) {
            $campData = $summaryObject.pov_summaries.$camp
            if ($campData) {
                if ($campData.key_points) {
                    foreach ($kp in $campData.key_points) {
                        if ($kp.stance -notin $validStances) {
                            Write-Warn "Invalid stance '$($kp.stance)' for $camp key_point — replacing with 'neutral'"
                            $kp.stance = 'neutral'
                        }
                    }
                }
                $pointCount = if ($campData.key_points) { @($campData.key_points).Count } else { 0 }
                $nullNodes  = if ($campData.key_points) { @($campData.key_points | Where-Object { $null -eq $_.taxonomy_node_id }).Count } else { 0 }
                Write-OK " $camp : $pointCount key points ($nullNodes unmapped)"
            } else {
                Write-Warn " $camp : no data returned — may not be relevant to this document"
            }
        }

        $factualClaimCount    = if ($summaryObject.factual_claims)    { @($summaryObject.factual_claims).Count }    else { 0 }
        $unmappedConceptCount = if ($summaryObject.unmapped_concepts) { @($summaryObject.unmapped_concepts).Count } else { 0 }

        Write-OK " factual_claims : $factualClaimCount"
        Write-OK " unmapped_concepts : $unmappedConceptCount"
        if ($unmappedConceptCount -gt 0) {
            Write-Warn "$unmappedConceptCount concept(s) didn't map to existing taxonomy nodes — review for taxonomy expansion."
        }

        # -- Density validation ---------------------------------------------------
        $densityCheck = Test-SummaryDensity -SummaryObject $summaryObject -Floors $densityFloors
        if ($densityCheck.Pass) {
            break
        }

        $densityRetryNudge = Build-DensityRetryNudge -Shortfalls $densityCheck.Shortfalls
        Write-Warn "Density check FAILED: $($densityCheck.Shortfalls -join '; ')"

        if ($densityAttempt -eq $maxDensityRetries) {
            Write-Warn "Accepting under-dense result after $($densityAttempt + 1) attempt(s)"
        }
    }

    # -- STEP 7 — Write summary file ------------------------------------------
    Write-Step "Writing summary file"

    $finalSummary = [ordered]@{
        doc_id            = $DocId
        taxonomy_version  = $taxonomyVersion
        generated_at      = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
        ai_model          = $Model
        temperature       = $Temperature
        pov_summaries     = $summaryObject.pov_summaries
        factual_claims    = $summaryObject.factual_claims
        unmapped_concepts = $summaryObject.unmapped_concepts
    }

    $summaryJson = $finalSummary | ConvertTo-Json -Depth 20
    try {
        Set-Content -Path $paths.SummaryFile -Value $summaryJson -Encoding UTF8
        Write-OK "Summary written to: summaries/$DocId.json"
    }
    catch {
        Write-Fail "Failed to write summary file — $($_.Exception.Message)"
        Write-Info "AI response was valid but could not be saved. Check disk space and permissions."
        throw
    }

    # -- STEP 8 — Update metadata.json ----------------------------------------
    Write-Step "Updating metadata"

    try {
        $metaRaw     = Get-Content $paths.MetadataFile -Raw
        $metaUpdated = $metaRaw | ConvertFrom-Json -AsHashtable

        $metaUpdated["summary_version"] = $taxonomyVersion
        $metaUpdated["summary_status"]  = "current"
        $metaUpdated["summary_updated"] = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")

        Set-Content -Path $paths.MetadataFile -Value ($metaUpdated | ConvertTo-Json -Depth 10) -Encoding UTF8
        Write-OK "metadata.json updated: summary_status=current, summary_version=$taxonomyVersion"
    }
    catch {
        Write-Warn "Summary written but metadata update failed — $($_.Exception.Message)"
        Write-Info "Run Invoke-POVSummary -Force -DocId '$DocId' to retry."
    }

    # -- STEP 9 — Conflict detection ------------------------------------------
    Write-Step "Running conflict detection"

    $today = Get-Date -Format "yyyy-MM-dd"

    if ($factualClaimCount -eq 0) {
        Write-Info "No factual claims to process."
    } else {
        foreach ($claim in $summaryObject.factual_claims) {

            $claimText   = $claim.claim
            $claimLabel  = $claim.claim_label
            $docPosition = $claim.doc_position
            $hintId      = $claim.potential_conflict_id
            $linkedNodes = if ($null -ne $claim.linked_taxonomy_nodes) { ,@($claim.linked_taxonomy_nodes) } else { ,@() }

            # Normalize stance value
            $stance = if ($docPosition -in @('supports','disputes','neutral','qualifies')) { $docPosition } else { 'neutral' }

            $newInstance = [ordered]@{
                doc_id       = $DocId
                stance       = $stance
                assertion    = $claimText
                date_flagged = $today
            }

            if ($hintId) {
                $existingPath = Join-Path $paths.ConflictsDir "$hintId.json"

                if (Test-Path $existingPath) {
                    $conflictData = Get-Content $existingPath -Raw | ConvertFrom-Json -AsHashtable
                    $alreadyLogged = $conflictData["instances"] | Where-Object { $_["doc_id"] -eq $DocId }
                    if ($alreadyLogged) {
                        Write-Info " SKIP duplicate conflict instance: $hintId (doc already logged)"
                    } else {
                        $conflictData["instances"] += $newInstance
                        if ($linkedNodes.Count -gt 0) {
                            $existing = @($conflictData["linked_taxonomy_nodes"])
                            $merged   = @(($existing + $linkedNodes) | Select-Object -Unique)
                            $conflictData["linked_taxonomy_nodes"] = $merged
                        }
                        Set-Content -Path $existingPath -Value ($conflictData | ConvertTo-Json -Depth 10) -Encoding UTF8
                        Write-OK " Appended to existing conflict: $hintId"
                    }
                } else {
                    Write-Warn " Suggested conflict '$hintId' not found — creating new file"
                    $newConflict = [ordered]@{
                        claim_id               = $hintId
                        claim_label            = if ($claimLabel) { $claimLabel } else { $claimText.Substring(0, [Math]::Min(80, $claimText.Length)) }
                        description            = $claimText
                        status                 = "open"
                        linked_taxonomy_nodes  = $linkedNodes
                        instances              = @($newInstance)
                        human_notes            = @()
                    }
                    Set-Content -Path $existingPath -Value ($newConflict | ConvertTo-Json -Depth 10) -Encoding UTF8
                    Write-OK " Created new conflict file: $hintId.json"
                }
            } else {
                $slug = $claimText.ToLower() -replace '[^\w\s]', '' -replace '\s+', '-'
                $slug = $slug.Substring(0, [Math]::Min(40, $slug.Length)).TrimEnd('-')
                $newId = "conflict-$slug-$($DocId.Substring(0,[Math]::Min(8,$DocId.Length)))"

                $existingMatch = Get-ChildItem $paths.ConflictsDir -Filter "*.json" |
                    Where-Object { $_.BaseName -like "*$($slug.Substring(0,[Math]::Min(20,$slug.Length)))*" } |
                    Select-Object -First 1

                if ($existingMatch) {
                    $conflictData = Get-Content $existingMatch.FullName -Raw | ConvertFrom-Json -AsHashtable
                    $alreadyLogged = $conflictData["instances"] | Where-Object { $_["doc_id"] -eq $DocId }
                    if (-not $alreadyLogged) {
                        $conflictData["instances"] += $newInstance
                        if ($linkedNodes.Count -gt 0) {
                            $existing = @($conflictData["linked_taxonomy_nodes"])
                            $merged   = @(($existing + $linkedNodes) | Select-Object -Unique)
                            $conflictData["linked_taxonomy_nodes"] = $merged
                        }
                        Set-Content -Path $existingMatch.FullName -Value ($conflictData | ConvertTo-Json -Depth 10) -Encoding UTF8
                        Write-OK " Appended to fuzzy-matched conflict: $($existingMatch.BaseName)"
                    }
                } else {
                    $newConflictPath = Join-Path $paths.ConflictsDir "$newId.json"
                    $newConflict = [ordered]@{
                        claim_id               = $newId
                        claim_label            = if ($claimLabel) { $claimLabel } else { $claimText.Substring(0, [Math]::Min(80, $claimText.Length)) }
                        description            = $claimText
                        status                 = "open"
                        linked_taxonomy_nodes  = $linkedNodes
                        instances              = @($newInstance)
                        human_notes            = @()
                    }
                    Set-Content -Path $newConflictPath -Value ($newConflict | ConvertTo-Json -Depth 10) -Encoding UTF8
                    Write-OK " Created new conflict file: $newId.json"
                }
            }
        }
    }

    # -- STEP 10 — Print human-readable summary to console --------------------
    Write-Host "`n$('═' * 72)" -ForegroundColor Cyan
    Write-Host " POV SUMMARY: $DocId" -ForegroundColor White
    Write-Host " Taxonomy v$taxonomyVersion | Model: $Model" -ForegroundColor Gray
    Write-Host "$('═' * 72)" -ForegroundColor Cyan

    foreach ($camp in $camps) {
        $campData = $summaryObject.pov_summaries.$camp
        if (-not $campData) { continue }

        $campColor = switch ($camp) {
            "accelerationist" { "Green"  }
            "safetyist"       { "Red"    }
            "skeptic"         { "Yellow" }
        }
        $campLabel = $camp.ToUpper()

        Write-Host "`n [$campLabel]" -ForegroundColor $campColor

        if ($campData.key_points) {
            $byCategory = $campData.key_points | Group-Object category
            foreach ($group in $byCategory) {
                Write-Host " $($group.Name):" -ForegroundColor White
                foreach ($pt in $group.Group) {
                    $nodeTag = if ($pt.taxonomy_node_id) { "[$($pt.taxonomy_node_id)]" } else { "[UNMAPPED]" }
                    $ptStance = if ($pt.stance) { $pt.stance } else { 'neutral' }
                    Write-Host " $nodeTag ($ptStance) $($pt.point)" -ForegroundColor Gray
                    if ($pt.verbatim) {
                        Write-Host " `"$($pt.verbatim)`"" -ForegroundColor DarkGray
                    }
                }
            }
        } else {
            Write-Host " (no key points extracted)" -ForegroundColor DarkGray
        }
    }

    if ($unmappedConceptCount -gt 0) {
        Write-Host "`n UNMAPPED CONCEPTS (potential new taxonomy nodes):" -ForegroundColor Magenta
        foreach ($concept in $summaryObject.unmapped_concepts) {
            Write-Host " [$($concept.suggested_pov) / $($concept.suggested_category)]" -ForegroundColor Magenta
            Write-Host " $($concept.concept)" -ForegroundColor Gray
            Write-Host " Reason: $($concept.reason)" -ForegroundColor DarkGray
        }
    }

    Write-Host "`n$('═' * 72)" -ForegroundColor Cyan
    Write-Host " Files written:" -ForegroundColor White
    Write-Host " summaries/$DocId.json" -ForegroundColor Green
    Write-Host " sources/$DocId/metadata.json (summary_status=current)" -ForegroundColor Green
    Write-Host "$('═' * 72)`n" -ForegroundColor Cyan
}