Public/Get-KnowledgeGaps.ps1

function Get-KnowledgeGaps {
    <#
    .SYNOPSIS
        Identifies tickets that should have KB articles but don't, or KB articles that are stale/incomplete.
    .DESCRIPTION
        Pulls incident tickets and existing knowledge base articles from ServiceNow (or file exports),
        then uses AI to compare ticket patterns against available KB content.
        Identifies missing articles, stale articles, and incomplete articles.
    .EXAMPLE
        Get-KnowledgeGaps -Provider ServiceNow -Instance 'company.service-now.com' -Credential $cred
    .EXAMPLE
        Get-KnowledgeGaps -Provider File -FilePath '.\tickets.json' -MinOccurrences 2 -OutputPath '.\gaps.html'
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('ServiceNow', 'Jira', 'File')]
        [string]$Provider = 'ServiceNow',

        [Parameter()]
        [string]$Instance,

        [Parameter()]
        [PSCredential]$Credential,

        [Parameter()]
        [string]$ApiKey,

        [Parameter()]
        [string]$BaseUrl,

        [Parameter()]
        [string]$Email,

        [Parameter()]
        [string]$FilePath,

        [Parameter()]
        [ValidateRange(1, 60)]
        [int]$MonthsBack = 6,

        [Parameter()]
        [int]$MinOccurrences = 3,

        [Parameter()]
        [switch]$IncludeExistingKB,

        [Parameter()]
        [ValidateSet('Anthropic', 'OpenAI', 'Ollama', 'Custom')]
        [string]$AIProvider = 'Anthropic',

        [Parameter()]
        [string]$AIApiKey,

        [Parameter()]
        [string]$AIModel,

        [Parameter()]
        [string]$AIEndpoint,

        [Parameter()]
        [string]$OutputPath,

        [Parameter()]
        [switch]$SkipAI
    )

    Write-Verbose "Analyzing knowledge gaps (Provider: $Provider, MonthsBack: $MonthsBack, MinOccurrences: $MinOccurrences)"

    $allTickets = @()
    $kbArticles = @()

    switch ($Provider) {
        'ServiceNow' {
            if (-not $Instance) {
                throw 'ServiceNow provider requires -Instance parameter.'
            }

            $authParams = @{ Instance = $Instance }
            if ($Credential) { $authParams['Credential'] = $Credential }
            if ($ApiKey) { $authParams['ApiKey'] = $ApiKey }

            $fields = 'number,short_description,description,state,priority,category,subcategory,opened_at,closed_at,resolved_at,assigned_to,close_notes,work_notes'
            $dateFilter = "sys_created_on>javascript:gs.monthsAgo($MonthsBack)"

            # Pull incidents
            Write-Verbose 'Querying ServiceNow incidents...'
            $incidents = Connect-ServiceNow @authParams -Method GET -Endpoint 'api/now/table/incident' `
                -QueryParameters @{
                    sysparm_query  = $dateFilter
                    sysparm_fields = $fields
                    sysparm_limit  = '10000'
                } -Paginate

            foreach ($inc in @($incidents)) {
                $allTickets += [PSCustomObject]@{
                    Number           = $inc.number
                    Type             = 'Incident'
                    ShortDescription = $inc.short_description
                    Description      = $inc.description
                    State            = $inc.state
                    Priority         = $inc.priority
                    Category         = $inc.category
                    Subcategory      = $inc.subcategory
                    OpenedAt         = $inc.opened_at
                    ClosedAt         = $inc.closed_at
                    ResolvedAt       = $inc.resolved_at
                    AssignedTo       = if ($inc.assigned_to -is [string]) { $inc.assigned_to } else { $inc.assigned_to.display_value }
                    CloseNotes       = $inc.close_notes
                    WorkNotes        = $inc.work_notes
                    Source           = 'ServiceNow'
                }
            }
            Write-Verbose "Found $(@($incidents).Count) incidents"

            # Pull KB articles (always or if IncludeExistingKB)
            Write-Verbose 'Querying ServiceNow knowledge base articles...'
            $kbFields = 'number,short_description,text,kb_knowledge_base,category,sys_updated_on,workflow_state,author'
            $kbQuery = 'workflow_state=published'

            $kbResults = Connect-ServiceNow @authParams -Method GET -Endpoint 'api/now/table/kb_knowledge' `
                -QueryParameters @{
                    sysparm_query  = $kbQuery
                    sysparm_fields = $kbFields
                    sysparm_limit  = '5000'
                } -Paginate

            foreach ($kb in @($kbResults)) {
                $kbArticles += [PSCustomObject]@{
                    Number           = $kb.number
                    Title            = $kb.short_description
                    Content          = $kb.text
                    KnowledgeBase    = $kb.kb_knowledge_base
                    Category         = $kb.category
                    LastUpdated      = $kb.sys_updated_on
                    WorkflowState    = $kb.workflow_state
                    Author           = $kb.author
                }
            }
            Write-Verbose "Found $($kbArticles.Count) published KB articles"
        }

        'Jira' {
            if (-not $BaseUrl) {
                throw 'Jira provider requires -BaseUrl parameter.'
            }
            if (-not $Email) {
                throw 'Jira provider requires -Email parameter.'
            }

            $jql = "issuetype in (Incident, Bug, ""Service Request"") AND created >= ""-${MonthsBack}m"""

            $jiraParams = @{
                BaseUrl  = $BaseUrl
                Email    = $Email
                Method   = 'GET'
                Endpoint = 'rest/api/3/search'
                QueryParameters = @{
                    jql        = $jql
                    maxResults = '100'
                    fields     = 'summary,description,status,priority,issuetype,created,resolutiondate,assignee,reporter,comment,components'
                }
                Paginate = $true
            }
            if ($ApiKey) { $jiraParams['ApiToken'] = $ApiKey }

            $jiraIssues = Connect-JiraSM @jiraParams

            foreach ($issue in @($jiraIssues)) {
                $allTickets += ConvertFrom-JiraIssue -Issue $issue
            }
            Write-Verbose "Found $(@($jiraIssues).Count) Jira issues"

            # Jira doesn't have a native KB — note this for the user
            Write-Warning 'Jira does not have a built-in knowledge base. KB gap analysis will be based on ticket patterns only.'
        }

        'File' {
            if (-not $FilePath) {
                throw 'File provider requires -FilePath parameter.'
            }

            $allTickets = @(Import-TicketExport -Path $FilePath -MonthsBack $MonthsBack)
            Write-Verbose "Imported $($allTickets.Count) tickets from file"

            # Check if there is a separate KB file
            $kbPath = [System.IO.Path]::ChangeExtension($FilePath, $null) + '-kb' + [System.IO.Path]::GetExtension($FilePath)
            if (Test-Path $kbPath) {
                Write-Verbose "Found KB file: $kbPath"
                $kbContent = Get-Content -Path $kbPath -Raw -Encoding UTF8
                $kbParsed = $kbContent | ConvertFrom-Json -ErrorAction SilentlyContinue
                if ($kbParsed) {
                    foreach ($kb in @($kbParsed)) {
                        $kbArticles += [PSCustomObject]@{
                            Number        = $kb.number
                            Title         = $kb.title
                            Content       = $kb.content
                            Category      = $kb.category
                            LastUpdated   = $kb.updated
                            WorkflowState = 'published'
                        }
                    }
                }
            }
        }
    }

    if ($allTickets.Count -eq 0) {
        Write-Warning 'No tickets found for knowledge gap analysis.'
        return @()
    }

    # ── SkipAI mode: basic gap detection ──
    if ($SkipAI) {
        Write-Verbose 'SkipAI mode: performing basic knowledge gap detection...'
        $gaps = Find-BasicKnowledgeGaps -Tickets $allTickets -KBArticles $kbArticles -MinOccurrences $MinOccurrences
        if ($OutputPath) {
            New-HtmlDashboard -ReportType 'KnowledgeGaps' -Data $gaps -OutputPath $OutputPath
            Write-Host "HTML report saved to: $OutputPath" -ForegroundColor Cyan
        }
        return $gaps
    }

    # ── AI-powered gap analysis ──
    Write-Verbose 'Generating AI knowledge gap analysis...'

    $templatePath = Join-Path $PSScriptRoot '..\Templates\knowledge-gap-prompt.txt'
    if (Test-Path $templatePath) {
        $promptTemplate = Get-Content -Path $templatePath -Raw -Encoding UTF8
    }
    else {
        $promptTemplate = @"
You are a knowledge management analyst. Compare the following ticket data with the existing knowledge base articles.
 
Identify Missing, Stale, and Incomplete articles.
 
TICKET DATA:
{ticket_data}
 
EXISTING KB ARTICLES:
{kb_articles}
"@

    }

    $ticketDataText = ($allTickets | ForEach-Object {
        "[$($_.Type)] $($_.Number) | $($_.OpenedAt) | Cat: $($_.Category) | $($_.ShortDescription) | Close Notes: $($_.CloseNotes)"
    }) -join "`n"

    $kbDataText = if ($kbArticles.Count -gt 0) {
        ($kbArticles | ForEach-Object {
            "$($_.Number) | $($_.Title) | Updated: $($_.LastUpdated) | Category: $($_.Category)"
        }) -join "`n"
    }
    else {
        '(No existing KB articles found)'
    }

    $prompt = $promptTemplate -replace '\{ticket_data\}', $ticketDataText -replace '\{kb_articles\}', $kbDataText

    $aiParams = @{
        Prompt   = $prompt
        Provider = $AIProvider
    }
    if ($AIApiKey)   { $aiParams['ApiKey']   = $AIApiKey }
    if ($AIModel)    { $aiParams['Model']    = $AIModel }
    if ($AIEndpoint) { $aiParams['Endpoint'] = $AIEndpoint }

    try {
        $aiResponse = Invoke-AICompletion @aiParams
    }
    catch {
        Write-Warning "AI analysis failed: $($_.Exception.Message). Falling back to basic gap detection."
        $gaps = Find-BasicKnowledgeGaps -Tickets $allTickets -KBArticles $kbArticles -MinOccurrences $MinOccurrences
        if ($OutputPath) {
            New-HtmlDashboard -ReportType 'KnowledgeGaps' -Data $gaps -OutputPath $OutputPath
            Write-Host "HTML report saved to: $OutputPath" -ForegroundColor Cyan
        }
        return $gaps
    }

    # Parse AI response into structured gap objects
    $gaps = ConvertFrom-AIGapResponse -AIResponse $aiResponse -Tickets $allTickets

    if ($OutputPath) {
        New-HtmlDashboard -ReportType 'KnowledgeGaps' -Data $gaps -OutputPath $OutputPath
        Write-Host "HTML report saved to: $OutputPath" -ForegroundColor Cyan
    }

    return $gaps
}


function Find-BasicKnowledgeGaps {
    <#
    .SYNOPSIS
        Basic knowledge gap detection without AI — identifies frequently recurring ticket topics with no KB article.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$Tickets,

        [Parameter()]
        [object[]]$KBArticles = @(),

        [Parameter()]
        [int]$MinOccurrences = 3
    )

    $gaps = @()

    # Group tickets by category
    $categoryGroups = $Tickets | Where-Object { $_.Category } | Group-Object Category | Where-Object { $_.Count -ge $MinOccurrences } | Sort-Object Count -Descending

    foreach ($group in $categoryGroups) {
        # Check if any KB article covers this category
        $hasKB = $KBArticles | Where-Object {
            $_.Category -like "*$($group.Name)*" -or $_.Title -like "*$($group.Name)*"
        }

        if (-not $hasKB) {
            $ticketNumbers = @($group.Group | ForEach-Object { $_.Number })
            $gaps += [PSCustomObject]@{
                GapType          = 'Missing'
                Topic            = $group.Name
                RelatedTickets   = $ticketNumbers
                SuggestedTitle   = "How to Resolve Common $($group.Name) Issues"
                SuggestedContent = "This article should cover the $($group.Count) incidents categorized as '$($group.Name)'. Common descriptions include: $(($group.Group | Select-Object -First 3 | ForEach-Object { $_.ShortDescription }) -join '; ')"
            }
        }
    }

    return $gaps
}


function ConvertFrom-AIGapResponse {
    <#
    .SYNOPSIS
        Parses AI response text into structured KnowledgeGap objects.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$AIResponse,

        [Parameter(Mandatory)]
        [object[]]$Tickets
    )

    $gaps = @()

    # Split by sections (numbered items, headers, or gap type markers)
    $sections = $AIResponse -split '(?m)^(?:#{1,3}\s*\d+\.?\s*|(?:\*\*)?(?:Missing|Stale|Incomplete)\s*(?:Article)?\s*(?:\d+)?:?\s*(?:\*\*)?)'

    foreach ($section in $sections) {
        if ([string]::IsNullOrWhiteSpace($section)) { continue }
        if ($section.Length -lt 20) { continue }

        # Determine gap type
        $gapType = 'Missing'
        if ($section -match '(?i)\bstale\b') { $gapType = 'Stale' }
        elseif ($section -match '(?i)\bincomplete\b') { $gapType = 'Incomplete' }

        # Extract topic/title
        $lines = $section.Trim() -split "`n"
        $topic = ($lines[0] -replace '\*\*', '' -replace '^#+\s*', '' -replace '^\d+\.\s*', '').Trim()
        if (-not $topic -or $topic.Length -lt 3) { continue }

        # Extract ticket numbers
        $ticketNumbers = @()
        $ticketMatches = [regex]::Matches($section, '(?:INC|CHG|PRB|REQ|RITM|[A-Z]+-)\d{5,10}')
        foreach ($match in $ticketMatches) {
            $ticketNumbers += $match.Value
        }
        $ticketNumbers = @($ticketNumbers | Select-Object -Unique)

        # Extract suggested title
        $suggestedTitle = $topic
        $titleMatch = [regex]::Match($section, '(?:Suggested\s*(?:Article\s*)?Title|Title)[\s:]+(.+?)(?:\n|$)', 'IgnoreCase')
        if ($titleMatch.Success) {
            $suggestedTitle = ($titleMatch.Groups[1].Value -replace '\*\*', '').Trim()
        }

        # Extract suggested content
        $suggestedContent = ''
        $contentMatch = [regex]::Match($section, '(?:Suggested\s*(?:Article\s*)?Content|Outline|Steps)[\s:]*(.+?)(?=\n\s*(?:\*\*(?:Gap|Missing|Stale|Incomplete)|#{1,3}|\d+\.)|\z)', 'IgnoreCase,Singleline')
        if ($contentMatch.Success) {
            $suggestedContent = $contentMatch.Groups[1].Value.Trim()
        }
        else {
            # Use remaining lines as suggested content
            $suggestedContent = ($lines | Select-Object -Skip 1 | Select-Object -Last 5) -join "`n"
        }

        $gaps += [PSCustomObject]@{
            GapType          = $gapType
            Topic            = $topic
            RelatedTickets   = $ticketNumbers
            SuggestedTitle   = $suggestedTitle
            SuggestedContent = $suggestedContent
        }
    }

    # If AI parsing yielded no structured gaps, return the raw response as a single gap
    if ($gaps.Count -eq 0 -and $AIResponse.Length -gt 50) {
        $gaps += [PSCustomObject]@{
            GapType          = 'Missing'
            Topic            = 'AI Analysis Results'
            RelatedTickets   = @($Tickets | ForEach-Object { $_.Number } | Select-Object -First 10)
            SuggestedTitle   = 'Knowledge Gap Analysis Results'
            SuggestedContent = $AIResponse
        }
    }

    return $gaps
}