Public/Update-AITSourceIndex.ps1

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

function Update-AITSourceIndex {
    <#
    .SYNOPSIS
        Rebuilds the sources/_index.json file for fast source enumeration.
    .DESCRIPTION
        Scans all source folders under sources/, reads each metadata.json,
        and writes a lightweight index at sources/_index.json. This turns
        Get-AITSource from O(N×2 file reads) to O(1 file read).
 
        Called automatically after Import-AITriadDocument and Invoke-POVSummary.
        Can also be invoked manually after editing metadata by hand.
    .PARAMETER Quiet
        Suppress progress output.
    .EXAMPLE
        Update-AITSourceIndex
    .EXAMPLE
        Update-AITSourceIndex -Quiet
    #>

    [CmdletBinding()]
    param(
        [switch]$Quiet
    )

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

    $SourcesDir = Get-SourcesDir
    if (-not (Test-Path $SourcesDir)) {
        if (-not $Quiet) { Write-Warning "Sources directory not found: $SourcesDir" }
        return
    }

    $SummariesDir = Get-SummariesDir
    $Folders = @(Get-ChildItem -Path $SourcesDir -Directory | Where-Object { $_.Name -ne '_inbox' })

    $Entries = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($Folder in $Folders) {
        $MetaPath = Join-Path $Folder.FullName 'metadata.json'
        if (-not (Test-Path $MetaPath)) { continue }

        try {
            $Meta = Get-Content -Raw -Path $MetaPath | ConvertFrom-Json
        } catch {
            if (-not $Quiet) { Write-Warning "Failed to parse ${MetaPath}: $_" }
            continue
        }

        $Props = $Meta.PSObject.Properties

        # Load summary statistics — prefer cached values in metadata
        $TotalClaims      = 0
        $ClaimsByPov      = @{ accelerationist = 0; safetyist = 0; skeptic = 0; situations = 0 }
        $TotalFacts       = 0
        $UnmappedConcepts = 0

        if ($Props['total_claims']) {
            $TotalClaims = [int]$Meta.total_claims
            if ($Props['total_facts']) { $TotalFacts = [int]$Meta.total_facts }
            if ($Props['unmapped_concepts'] -and $Meta.unmapped_concepts -is [int]) { $UnmappedConcepts = [int]$Meta.unmapped_concepts }
            if ($Props['claims_by_pov'] -and $Meta.claims_by_pov) {
                $Cbp = $Meta.claims_by_pov
                $CbpProps = $Cbp.PSObject.Properties
                if ($CbpProps['accelerationist']) { $ClaimsByPov['accelerationist'] = [int]$Cbp.accelerationist }
                if ($CbpProps['safetyist'])       { $ClaimsByPov['safetyist']       = [int]$Cbp.safetyist }
                if ($CbpProps['skeptic'])         { $ClaimsByPov['skeptic']         = [int]$Cbp.skeptic }
                if ($CbpProps['situations'])      { $ClaimsByPov['situations']      = [int]$Cbp.situations }
            }
        } elseif ($null -ne $SummariesDir) {
            # Fall back to computing from summary file (legacy migration path)
            $SummaryPath = Join-Path $SummariesDir "$($Meta.id).json"
            if (Test-Path $SummaryPath) {
                try {
                    $Summary = Get-Content -Raw -Path $SummaryPath | ConvertFrom-Json
                    if ($Summary.factual_claims) {
                        $TotalClaims = @($Summary.factual_claims).Count
                    }
                    foreach ($Claim in @($Summary.factual_claims)) {
                        if (-not $Claim.PSObject.Properties['linked_taxonomy_nodes']) { continue }
                        foreach ($NodeId in @($Claim.linked_taxonomy_nodes)) {
                            if     ($NodeId -like 'acc-*') { $ClaimsByPov['accelerationist']++ }
                            elseif ($NodeId -like 'saf-*') { $ClaimsByPov['safetyist']++ }
                            elseif ($NodeId -like 'skp-*') { $ClaimsByPov['skeptic']++ }
                            elseif ($NodeId -like 'sit-*') { $ClaimsByPov['situations']++ }
                        }
                    }
                    foreach ($PovName in @('accelerationist', 'safetyist', 'skeptic')) {
                        $PovData = $Summary.pov_summaries.$PovName
                        if ($PovData -and $PovData.key_points) {
                            $TotalFacts += @($PovData.key_points).Count
                        }
                    }
                    if ($Summary.unmapped_concepts) {
                        $UnmappedConcepts = @($Summary.unmapped_concepts).Count
                    }
                } catch {
                    if (-not $Quiet) { Write-Verbose "Could not parse summary for $($Meta.id): $_" }
                }
            }
        }

        $Entry = [ordered]@{
            id               = $Meta.id
            title            = if ($Props['title'])          { $Meta.title }          else { $null }
            date_published   = if ($Props['date_published']) { $Meta.date_published } else { $null }
            date_ingested    = if ($Props['date_ingested'])  { $Meta.date_ingested }  else { $null }
            source_type      = if ($Props['source_type'])    { $Meta.source_type }    else { $null }
            pov_tags         = if ($Props['pov_tags'])        { @($Meta.pov_tags) }   else { @() }
            topic_tags       = if ($Props['topic_tags'])      { @($Meta.topic_tags) } else { @() }
            summary_status   = if ($Props['summary_status']) { $Meta.summary_status } else { $null }
            total_claims     = $TotalClaims
            claims_by_pov    = $ClaimsByPov
            total_facts      = $TotalFacts
            unmapped_concepts = $UnmappedConcepts
            one_liner        = if ($Props['one_liner'])      { $Meta.one_liner }      else { $null }
        }

        $Entries.Add($Entry)
    }

    $Index = [ordered]@{
        generated_at = (Get-Date -Format 'o')
        count        = $Entries.Count
        sources      = @($Entries)
    }

    $IndexPath = Join-Path $SourcesDir '_index.json'
    $Index | ConvertTo-Json -Depth 5 | Write-Utf8NoBom -Path $IndexPath

    if (-not $Quiet) {
        Write-Host " Index updated: _index.json ($($Entries.Count) sources)" -ForegroundColor Green
    }
}