Public/Invoke-GraphQuery.ps1

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

function Invoke-GraphQuery {
    <#
    .SYNOPSIS
        Answers natural-language questions by reasoning over the taxonomy graph.
    .DESCRIPTION
        Takes a natural-language question, loads the full taxonomy graph (nodes,
        attributes, edges), and sends it to an LLM that reasons over the graph
        structure to produce a grounded answer.

        The LLM traces paths, follows edge chains, and considers node attributes
        to answer questions about relationships, assumptions, tensions, and gaps
        in the AI policy debate.
    .PARAMETER Question
        The natural-language question to answer.
    .PARAMETER IncludeConflicts
        Include conflict data in the graph context for conflict-aware reasoning.
    .PARAMETER StatusFilter
        Only include edges with this approval status. Default: approved.
        Use 'all' to include all edges regardless of status.
    .PARAMETER Model
        AI model to use. Defaults to 'gemini-2.5-flash'.
    .PARAMETER ApiKey
        AI API key. If omitted, resolved via backend-specific env var or AI_API_KEY.
    .PARAMETER Temperature
        Sampling temperature (0.0-1.0). Default: 0.3.
    .PARAMETER Raw
        Return the raw JSON response instead of formatted output.
    .PARAMETER RepoRoot
        Path to the repository root.
    .EXAMPLE
        Invoke-GraphQuery "What assumptions does the safetyist position share with the accelerationist position?"
    .EXAMPLE
        Invoke-GraphQuery "Which claims have no empirical support?" -StatusFilter all
    .EXAMPLE
        Invoke-GraphQuery "How does the skeptic position respond to existential risk arguments?" -IncludeConflicts
    .EXAMPLE
        Invoke-GraphQuery "What would change if scaling laws stopped holding?" -Raw
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Question,

        [switch]$IncludeConflicts,

        [ValidateSet('proposed', 'approved', 'rejected', 'all')]
        [string]$StatusFilter = 'approved',

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

        [string]$ApiKey = '',

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

        [switch]$Raw,

        [string]$RepoRoot = $script:RepoRoot
    )

    Set-StrictMode -Version Latest

    # ── Step 1: Validate environment ──
    Write-Step 'Validating environment'

    $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)) {
        Write-Fail 'No API key found. Set GEMINI_API_KEY, ANTHROPIC_API_KEY, or AI_API_KEY.'
        throw 'No API key configured'
    }

    $TaxDir = Get-TaxonomyDir

    # ── Step 2: Load full graph ──
    Write-Step 'Loading taxonomy graph'

    $PovFiles = @('accelerationist', 'safetyist', 'skeptic', 'cross-cutting')
    $GraphNodes = [System.Collections.Generic.List[PSObject]]::new()
    $NodePovMap = @{}

    foreach ($PovKey in $PovFiles) {
        $FilePath = Join-Path $TaxDir "$PovKey.json"
        if (-not (Test-Path $FilePath)) { continue }

        $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json
        foreach ($Node in $FileData.nodes) {
            $NodeEntry = [ordered]@{
                id          = $Node.id
                pov         = $PovKey
                label       = $Node.label
                description = $Node.description
            }
            if ($Node.PSObject.Properties['category']) {
                $NodeEntry['category'] = $Node.category
            }
            if ($Node.PSObject.Properties['graph_attributes']) {
                $NodeEntry['graph_attributes'] = $Node.graph_attributes
            }
            if ($PovKey -eq 'cross-cutting' -and $Node.PSObject.Properties['interpretations']) {
                $NodeEntry['interpretations'] = $Node.interpretations
            }
            $GraphNodes.Add([PSCustomObject]$NodeEntry)
            $NodePovMap[$Node.id] = $PovKey
        }
    }

    Write-OK "Loaded $($GraphNodes.Count) nodes"

    # ── Step 3: Load edges ──
    $EdgesPath = Join-Path $TaxDir 'edges.json'
    $GraphEdges = @()
    if (Test-Path $EdgesPath) {
        $EdgesData = Get-Content -Raw -Path $EdgesPath | ConvertFrom-Json
        if ($StatusFilter -eq 'all') {
            $GraphEdges = @($EdgesData.edges)
        } else {
            $GraphEdges = @($EdgesData.edges | Where-Object { $_.status -eq $StatusFilter })
        }
    }

    Write-OK "Loaded $($GraphEdges.Count) edges (filter: $StatusFilter)"

    # ── Step 4: Optionally load conflicts ──
    $ConflictData = @()
    if ($IncludeConflicts) {
        $ConflictDir = Get-ConflictsDir
        if (Test-Path $ConflictDir) {
            foreach ($File in Get-ChildItem -Path $ConflictDir -Filter '*.json' -File) {
                try {
                    $Conflict = Get-Content -Raw -Path $File.FullName | ConvertFrom-Json
                    $ConflictData += [ordered]@{
                        claim_id             = $Conflict.claim_id
                        claim_label          = $Conflict.claim_label
                        description          = $Conflict.description
                        status               = $Conflict.status
                        linked_taxonomy_nodes = $Conflict.linked_taxonomy_nodes
                        instance_count       = @($Conflict.instances).Count
                    }
                } catch {
                    Write-Warn "Failed to load conflict $($File.Name): $_"
                }
            }
            Write-OK "Loaded $($ConflictData.Count) conflicts"
        }
    }

    # ── Step 5: Build compact edge list for context ──
    $CompactEdges = foreach ($Edge in $GraphEdges) {
        $Entry = [ordered]@{
            source     = $Edge.source
            target     = $Edge.target
            type       = $Edge.type
            confidence = $Edge.confidence
            status     = $Edge.status
        }
        if ($Edge.PSObject.Properties['rationale'] -and $Edge.rationale) {
            $Entry['rationale'] = $Edge.rationale
        }
        if ($Edge.PSObject.Properties['bidirectional'] -and $Edge.bidirectional) {
            $Entry['bidirectional'] = $true
        }
        $Entry
    }

    # ── Step 6: Build prompt ──
    Write-Step 'Building prompt'

    $SystemPrompt = Get-Prompt -Name 'graph-query'
    $SchemaPrompt = Get-Prompt -Name 'graph-query-schema'

    $NodesJson = $GraphNodes | ConvertTo-Json -Depth 10
    $EdgesJson = $CompactEdges | ConvertTo-Json -Depth 5

    $FullPrompt = @"
$SystemPrompt

--- USER QUESTION ---
$Question

--- TAXONOMY NODES ($($GraphNodes.Count) nodes) ---
$NodesJson

--- GRAPH EDGES ($($GraphEdges.Count) edges, status filter: $StatusFilter) ---
$EdgesJson
"@


    if ($IncludeConflicts -and $ConflictData.Count -gt 0) {
        $ConflictJson = $ConflictData | ConvertTo-Json -Depth 5
        $FullPrompt += @"

--- CONFLICTS ($($ConflictData.Count) conflicts) ---
$ConflictJson
"@

    }

    $FullPrompt += @"

$SchemaPrompt
"@


    $PromptTokens = [Math]::Round($FullPrompt.Length / 4)
    Write-Info "Prompt: ~$PromptTokens tokens est."

    # ── Step 7: Call AI API ──
    Write-Step 'Querying graph'

    $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
    try {
        $Result = Invoke-AIApi `
            -Prompt $FullPrompt `
            -Model $Model `
            -ApiKey $ResolvedKey `
            -Temperature $Temperature `
            -MaxTokens 8192 `
            -JsonMode `
            -TimeoutSec 120
    } catch {
        Write-Fail "API call failed: $_"
        throw
    }
    $Stopwatch.Stop()
    Write-OK "Response in $([Math]::Round($Stopwatch.Elapsed.TotalSeconds, 1))s"

    # ── Step 8: Parse response ──
    $ResponseText = $Result.Text -replace '^\s*```json\s*', '' -replace '\s*```\s*$', ''
    try {
        $Response = $ResponseText | ConvertFrom-Json -Depth 20
    } catch {
        Write-Warn 'JSON parse failed, attempting repair...'
        $Repaired = Repair-TruncatedJson -Text $ResponseText
        try {
            $Response = $Repaired | ConvertFrom-Json -Depth 20
        } catch {
            Write-Fail 'Could not parse response'
            Write-Host $ResponseText -ForegroundColor DarkGray
            return
        }
    }

    # ── Step 9: Output ──
    if ($Raw) {
        return $Response
    }

    # Formatted output
    Write-Host ''
    Write-Host '══════════════════════════════════════════════════════════════' -ForegroundColor Cyan
    Write-Host " Q: $Question" -ForegroundColor White
    Write-Host '══════════════════════════════════════════════════════════════' -ForegroundColor Cyan
    Write-Host ''

    # Answer
    if ($Response.PSObject.Properties['answer']) {
        Write-Host $Response.answer -ForegroundColor White
        Write-Host ''
    }

    # Confidence
    if ($Response.PSObject.Properties['confidence']) {
        $ConfPct = [Math]::Round($Response.confidence * 100)
        $ConfColor = if ($ConfPct -ge 80) { 'Green' } elseif ($ConfPct -ge 50) { 'Yellow' } else { 'Red' }
        Write-Host " Confidence: $ConfPct%" -ForegroundColor $ConfColor
    }

    # Referenced nodes
    if ($Response.PSObject.Properties['referenced_nodes'] -and $Response.referenced_nodes) {
        Write-Host ''
        Write-Host ' Referenced Nodes:' -ForegroundColor Cyan
        foreach ($Ref in @($Response.referenced_nodes)) {
            # Guard against missing properties (LLM response format varies)
            $RefPov   = if ($Ref.PSObject.Properties['pov'])   { $Ref.pov }   else { '' }
            $RefId    = if ($Ref.PSObject.Properties['id'])    { $Ref.id }    else { '?' }
            $RefLabel = if ($Ref.PSObject.Properties['label']) { $Ref.label } else { '' }

            $PovColor = switch ($RefPov) {
                'accelerationist' { 'Blue' }
                'safetyist'       { 'Green' }
                'skeptic'         { 'Yellow' }
                'cross-cutting'   { 'Magenta' }
                default           { 'Gray' }
            }
            Write-Host " [$RefPov]" -NoNewline -ForegroundColor $PovColor
            Write-Host " $RefId" -NoNewline -ForegroundColor White
            Write-Host " — $RefLabel" -ForegroundColor DarkGray
            if ($Ref.PSObject.Properties['relevance'] -and $Ref.relevance) {
                Write-Host " $($Ref.relevance)" -ForegroundColor Gray
            }
        }
    }

    # Paths traced
    if ($Response.PSObject.Properties['paths_traced'] -and $Response.paths_traced) {
        $Paths = @($Response.paths_traced)
        if ($Paths.Count -gt 0) {
            Write-Host ''
            Write-Host ' Paths Traced:' -ForegroundColor Cyan
            foreach ($Path in $Paths) {
                $PathDesc = if ($Path.PSObject.Properties['description']) { $Path.description } else { '' }
                Write-Host " $PathDesc" -ForegroundColor White
                if ($Path.PSObject.Properties['nodes'] -and $Path.nodes) {
                    $NodeIds = @($Path.nodes)
                    $EdgeTypes = if ($Path.PSObject.Properties['edge_types']) { @($Path.edge_types) } else { @() }
                    $PathStr = ''
                    for ($i = 0; $i -lt $NodeIds.Count; $i++) {
                        $PathStr += $NodeIds[$i]
                        if ($i -lt $NodeIds.Count - 1) {
                            $EdgeLabel = if ($i -lt $EdgeTypes.Count) { $EdgeTypes[$i] } else { '?' }
                            $PathStr += " --[$EdgeLabel]--> "
                        }
                    }
                    Write-Host " $PathStr" -ForegroundColor DarkGray
                }
            }
        }
    }

    # Limitations
    if ($Response.PSObject.Properties['limitations'] -and $Response.limitations) {
        Write-Host ''
        Write-Host ' Limitations:' -ForegroundColor Yellow
        Write-Host " $($Response.limitations)" -ForegroundColor DarkGray
    }

    Write-Host ''
    Write-Host '══════════════════════════════════════════════════════════════' -ForegroundColor Cyan
    Write-Host ''

    return $Response
}