AITriad.psm1

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

#Requires -Version 5.1
Set-StrictMode -Version Latest

# ─────────────────────────────────────────────────────────────────────────────
# PowerShell 5.1 compatibility shims
# ─────────────────────────────────────────────────────────────────────────────
if (-not (Get-Variable IsWindows -Scope Global -ErrorAction SilentlyContinue)) {
    # PS 5.1 is Windows-only, so these are always fixed values
    Set-Variable -Name IsWindows -Value $true  -Scope Global -Option ReadOnly -Force
    Set-Variable -Name IsMacOS   -Value $false -Scope Global -Option ReadOnly -Force
    Set-Variable -Name IsLinux   -Value $false -Scope Global -Option ReadOnly -Force
}

function script:ConvertTo-Hashtable {
    <# .SYNOPSIS Recursively converts PSCustomObject to ordered hashtable (5.1 compat for -AsHashtable). #>
    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)] $InputObject)
    process {
        if ($null -eq $InputObject) { return $null }
        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
            $list = [System.Collections.ArrayList]::new()
            foreach ($item in $InputObject) { $null = $list.Add((ConvertTo-Hashtable $item)) }
            return ,$list
        }
        if ($InputObject -is [PSObject] -and $InputObject -isnot [ValueType] -and $InputObject -isnot [string]) {
            $hash = [ordered]@{}
            foreach ($prop in $InputObject.PSObject.Properties) {
                $hash[$prop.Name] = ConvertTo-Hashtable $prop.Value
            }
            return $hash
        }
        return $InputObject
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Module root paths
# Supports both dev layout (scripts/AITriad/) and PSGallery install (flat module dir)
# ─────────────────────────────────────────────────────────────────────────────
$script:ModuleRoot = $PSScriptRoot

# Detect if we're in a dev repo (scripts/AITriad/) or a PSGallery install
$_resolvedParent = Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') '..') -ErrorAction SilentlyContinue
if ($_resolvedParent) { $_candidateRepoRoot = $_resolvedParent.Path } else { $_candidateRepoRoot = $null }
if ($_candidateRepoRoot -and (Test-Path (Join-Path $_candidateRepoRoot '.aitriad.json'))) {
    $script:RepoRoot = $_candidateRepoRoot
    $script:IsDevInstall = $true
} elseif ($_candidateRepoRoot -and (Test-Path (Join-Path $_candidateRepoRoot 'CLAUDE.md'))) {
    $script:RepoRoot = $_candidateRepoRoot
    $script:IsDevInstall = $true
} else {
    # PSGallery or standalone install — module root IS the root
    $script:RepoRoot = $PSScriptRoot
    $script:IsDevInstall = $false
}

# ─────────────────────────────────────────────────────────────────────────────
# ClaimsByPov — per-POV claim counts for AITSource objects
# ─────────────────────────────────────────────────────────────────────────────
class ClaimsByPov {
    [int]$Accelerationist
    [int]$Safetyist
    [int]$Skeptic
    [int]$Situations
}

# ─────────────────────────────────────────────────────────────────────────────
# AITModelInfo — model and extraction parameters used to generate a summary
# ─────────────────────────────────────────────────────────────────────────────
class AITModelInfo {
    [string] $Model
    [double] $Temperature
    [int]    $MaxTokens
    [string] $ExtractionMode      # fire | single_shot | auto_fire
    [string] $TaxonomyFilter      # rag | full | rag_per_chunk
    [int]    $TaxonomyNodes
    [double] $FireConfidenceThreshold
    [bool]   $Chunked
    [int]    $ChunkCount
    [PSObject]$FireStats           # api_calls, iterations, claims_total, etc.
}

# ─────────────────────────────────────────────────────────────────────────────
# AITSource — typed representation of a source document + summary statistics
# ─────────────────────────────────────────────────────────────────────────────
class AITSource {
    [string]       $Id
    [string]       $Title
    [string]       $Url
    [string[]]     $Authors
    [string]       $DatePublished
    [string]       $DateIngested
    [string]       $ImportTime
    [string]       $SourceTime
    [string]       $SourceType
    [string[]]     $PovTags
    [string[]]     $TopicTags
    [string[]]     $RolodexAuthorIds
    [string]       $ArchiveStatus
    [string]       $SummaryVersion
    [string]       $SummaryStatus
    [string]       $SummaryUpdated
    [string]       $OneLiner
    [string]       $MDPath
    [string]       $Directory

    # Summary statistics (populated when summary exists)
    [int]          $TotalClaims
    [ClaimsByPov]  $ClaimsByPov
    [int]          $TotalFacts
    [int]          $UnmappedConcepts
    [AITModelInfo] $ModelInfo
}

Update-TypeData -TypeName AITSource -MemberType AliasProperty -MemberName DocId -Value Id -Force

# ─────────────────────────────────────────────────────────────────────────────
# TaxonomyNode class — must live in .psm1 for PowerShell type resolution
# ─────────────────────────────────────────────────────────────────────────────
class TaxonomyNode {
    [string]$POV
    [string]$Id
    [string]$Label
    [string]$Description
    [string]$Category
    [string]$ParentId
    [string]$ParentRelationship
    [string]$ParentRationale
    [string[]]$Children
    [string[]]$CrossCuttingRefs
    [string[]]$SituationRefs
    [PSObject]$Interpretations
    [string[]]$LinkedNodes
    [double]$Score
    [PSObject]$GraphAttributes
}

# ─────────────────────────────────────────────────────────────────────────────
# Module-scoped taxonomy store
# ─────────────────────────────────────────────────────────────────────────────
$script:TaxonomyData = @{}
$script:CachedEmbeddings = $null  # Lazy-loaded by Get-RelevantTaxonomyNodes

# ─────────────────────────────────────────────────────────────────────────────
# Load ai-models.json — single source of truth for backend/model lists
# ─────────────────────────────────────────────────────────────────────────────
$script:AIModelConfig  = $null
$script:ValidModelIds  = @()

# Try repo root first (dev), then module root (PSGallery install)
$AIModelsPath = Join-Path $script:RepoRoot 'ai-models.json'
if (-not (Test-Path $AIModelsPath)) {
    $AIModelsPath = Join-Path $script:ModuleRoot 'ai-models.json'
}
if (Test-Path $AIModelsPath) {
    try {
        $script:AIModelConfig = Get-Content -Raw -Path $AIModelsPath | ConvertFrom-Json
        $script:ValidModelIds = @($script:AIModelConfig.models | ForEach-Object { $_.id })
        Write-Verbose "AI Models: loaded $($script:ValidModelIds.Count) models from ai-models.json"
    }
    catch {
        Write-Warning "AI Models: failed to load ai-models.json — $($_.Exception.Message)"
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Dot-source Private/ then Public/ functions
# ─────────────────────────────────────────────────────────────────────────────
foreach ($Scope in @('Private', 'Public')) {
    $Dir = Join-Path $PSScriptRoot $Scope
    if (Test-Path $Dir) {
        foreach ($File in Get-ChildItem -Path $Dir -Filter '*.ps1' -File) {
            . $File.FullName
        }
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Import companion modules
# Dev: scripts/ dir (parent of AITriad/)
# PSGallery: bundled in module root alongside AITriad.psm1
# ─────────────────────────────────────────────────────────────────────────────
$_companionDirs = @(
    (Join-Path $script:ModuleRoot '..')     # Dev layout: scripts/
    $script:ModuleRoot                       # PSGallery: bundled in module root
)

foreach ($_name in @('DocConverters', 'AIEnrich')) {
    $_loaded = $false
    foreach ($_dir in $_companionDirs) {
        $_path = Join-Path $_dir "$_name.psm1"
        if (Test-Path $_path) {
            try {
                Import-Module $_path -Force
                $_loaded = $true
                break
            }
            catch {
                Write-Warning "Failed to import ${_name}.psm1: $_ — related features will be unavailable."
            }
        }
    }
    if (-not $_loaded) {
        Write-Verbose "${_name}.psm1 not found — related features will be unavailable."
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Load taxonomy data at import time (same logic as standalone Taxonomy.psm1)
# ─────────────────────────────────────────────────────────────────────────────
$TaxonomyDir = Get-TaxonomyDir
if (Test-Path $TaxonomyDir) {
    foreach ($File in Get-ChildItem -Path $TaxonomyDir -Filter '*.json' -File) {
        if ($File.Name -in 'embeddings.json', 'edges.json', 'policy_actions.json', '_archived_edges.json') { continue }
        try {
            $Json    = Get-Content -Raw -Path $File.FullName | ConvertFrom-Json
            $PovName = $File.BaseName.ToLower()
            $script:TaxonomyData[$PovName] = $Json
            Write-Verbose "Taxonomy: loaded '$PovName' ($($Json.nodes.Count) nodes) from $($File.Name)"
        }
        catch {
            Write-Warning "Taxonomy: failed to load $($File.Name): $_ — this POV will be unavailable until the file is fixed."
        }
    }
}

if ($script:TaxonomyData.Count -eq 0) {
    Write-Warning "Taxonomy: no valid JSON files loaded from $TaxonomyDir — most commands will not work."
}

# Load policy registry
$script:PolicyRegistry = $null
$RegistryFile = Join-Path $TaxonomyDir 'policy_actions.json'
if (Test-Path $RegistryFile) {
    try {
        $script:PolicyRegistry = Get-Content -Raw -Path $RegistryFile | ConvertFrom-Json
        Write-Verbose "Policy registry: loaded $($script:PolicyRegistry.policy_count) policies"
    }
    catch {
        Write-Warning "Policy registry: failed to load — $($_.Exception.Message)"
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# Backward-compatibility & convenience aliases
# ─────────────────────────────────────────────────────────────────────────────
Set-Alias -Name 'Import-Document'  -Value 'Import-AITriadDocument'  -Scope Global
Set-Alias -Name 'TaxonomyEditor'   -Value 'Show-TaxonomyEditor'    -Scope Global
Set-Alias -Name 'POViewer'         -Value 'Show-POViewer'           -Scope Global
Set-Alias -Name 'SummaryViewer'    -Value 'Show-SummaryViewer'      -Scope Global
Set-Alias -Name 'Redo-Snapshots'   -Value 'Update-Snapshot'         -Scope Global
Set-Alias -Name 'Install-AITdependencies' -Value 'Install-AIDependencies' -Scope Global

# ─────────────────────────────────────────────────────────────────────────────
# Deprecation wrappers — old cmdlet names delegate to new names
# ─────────────────────────────────────────────────────────────────────────────
function Find-CrossCuttingCandidates {
    <#
    .SYNOPSIS
        DEPRECATED: Use Find-SituationCandidates instead.
    #>

    [CmdletBinding()]
    param()

    Write-Warning (New-ActionableError -Goal 'run Find-CrossCuttingCandidates' `
        -Problem 'Find-CrossCuttingCandidates was renamed in the Situations migration' `
        -Location 'AITriad module' `
        -NextSteps 'Use Find-SituationCandidates instead' `
        -PassThru)

    Find-SituationCandidates @PSBoundParameters
}

# ─────────────────────────────────────────────────────────────────────────────
# Export public surface
# ─────────────────────────────────────────────────────────────────────────────
Export-ModuleMember -Function @(
    'Get-Tax'
    'Update-TaxEmbeddings'
    'Import-AITriadDocument'
    'Invoke-POVSummary'
    'Invoke-BatchSummary'
    'Find-Conflict'
    'Find-AITSource'
    'Save-AITSource'
    'Save-WaybackUrl'
    'Invoke-PIIAudit'
    'Update-Snapshot'
    'Show-TaxonomyEditor'
    'Show-POViewer'
    'Show-SummaryViewer'
    'Show-AITriadHelp'
    'Get-TaxonomyHealth'
    'Measure-TaxonomyBaseline'
    'Invoke-TaxonomyProposal'
    'Compare-Taxonomy'
    'Get-AITSource'
    'Get-Summary'
    'Invoke-AttributeExtraction'
    'Invoke-EdgeDiscovery'
    'Get-GraphNode'
    'Find-GraphPath'
    'Approve-Edge'
    'Approve-TaxonomyProposal'
    'Get-Edge'
    'Set-Edge'
    'Invoke-GraphQuery'
    'Get-ConflictEvolution'
    'Export-TaxonomyToGraph'
    'Install-GraphDatabase'
    'Invoke-CypherQuery'
    'Show-GraphOverview'
    'Get-TopicFrequency'
    'Get-IngestionPriority'
    'Find-SituationCandidates'
    'Find-CrossCuttingCandidates'
    'Show-TriadDialogue'
    'Register-AIBackend'
    'Install-AITriadData'
    'Install-AIDependencies'
    'Test-Dependencies'
    'Find-PossibleFallacy'
    'Find-PolicyAction'
    'Get-Policy'
    'Update-PolicyRegistry'
    'Show-FallacyInfo'
    'Test-TaxonomyIntegrity'
    'Invoke-HierarchyProposal'
    'Set-TaxonomyHierarchy'
    'Invoke-SchemaMigration'
    'Invoke-PolicyRefinement'
    'Repair-UnmappedConcepts'
    'Invoke-AITDebate'
    'Convert-MD2PDF'
    'Show-Markdown'
    'Show-DebateDiagnostics'
    'Show-DebateHarvest'
    'Repair-DebateOutput'
    'Get-AITSBOM'
    'Test-OntologyCompliance'
    'Get-RelevantTaxonomyNodes'
    'Invoke-QbafConflictAnalysis'
    'Test-ExtractionQuality'
) -Alias @(
    'Import-Document'
    'TaxonomyEditor'
    'POViewer'
    'SummaryViewer'
    'Redo-Snapshots'
    'Show-MD'
)

# ─────────────────────────────────────────────────────────────────────────────
# Register -Model argument completers (module-scoped, captures $script:ValidModelIds)
# ─────────────────────────────────────────────────────────────────────────────
$_modelCompleter = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    $script:ValidModelIds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }
}

foreach ($_cmd in @(
    'Invoke-POVSummary', 'Invoke-BatchSummary', 'Invoke-AttributeExtraction',
    'Invoke-EdgeDiscovery', 'Invoke-GraphQuery', 'Invoke-TaxonomyProposal',
    'Invoke-HierarchyProposal', 'Invoke-PolicyRefinement', 'Invoke-AITDebate',
    'Import-AITriadDocument', 'Find-PolicyAction', 'Find-PossibleFallacy',
    'Find-SituationCandidates', 'Get-ConflictEvolution', 'Get-Edge',
    'Get-IngestionPriority', 'Get-RelevantTaxonomyNodes', 'Get-TopicFrequency',
    'Show-TriadDialogue'
)) {
    Register-ArgumentCompleter -CommandName $_cmd -ParameterName 'Model' -ScriptBlock $_modelCompleter
}