Public/Get-ImportReport.ps1

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

function Get-ImportReport {
    <#
    .SYNOPSIS
        Batch quality audit of imported documents.
    .DESCRIPTION
        Scans imported documents for under-extraction, OCR artifacts,
        date anomalies, POV inconsistencies, and density outliers.
    .PARAMETER Today
        Audit only documents ingested today.
    .PARAMETER Since
        Audit documents ingested on or after this date (yyyy-MM-dd).
    .PARAMETER DocId
        Wildcard pattern to filter document IDs.
    .PARAMETER PassThru
        Return structured objects for piping.
    .EXAMPLE
        Get-ImportReport -Today
    .EXAMPLE
        Get-ImportReport -Since 2026-06-15 -PassThru
    .LINK
        Show-AITriadHelp
    .LINK
        Import-AITriadDocument
    .LINK
        Save-AITSource
    .LINK
        Find-AITSource
    .LINK
        Get-AITSource
    .LINK
        Update-AITSourceIndex
    .LINK
        Get-IngestionPriority
    #>

    [CmdletBinding(DefaultParameterSetName = 'All')]
    param(
        [Parameter(ParameterSetName = 'Today')]
        [switch]$Today,

        [Parameter(ParameterSetName = 'Since')]
        [string]$Since,

        [Parameter(ParameterSetName = 'Pattern')]
        [string]$DocId,

        [switch]$PassThru
    )

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

    $SourcesDir = Get-SourcesDir
    $AllDocs = Get-ChildItem -Path $SourcesDir -Directory

    if ($Today) { $Since = (Get-Date -Format 'yyyy-MM-dd') }

    $Results = [System.Collections.Generic.List[PSObject]]::new()

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

        $Meta = Get-Content $MetaPath -Raw | ConvertFrom-Json

        if ($DocId -and $Dir.Name -notlike $DocId) { continue }

        if ($Since) {
            $Ingested = $null
            if ($Meta.PSObject.Properties['date_ingested'] -and $Meta.date_ingested) {
                $Ingested = $Meta.date_ingested
            }
            if (-not $Ingested -or $Ingested -lt $Since) { continue }
        }

        $Flags = [System.Collections.Generic.List[string]]::new()
        $SnapshotPath = Join-Path $Dir.FullName 'snapshot.md'
        $SnapshotSizeKB = 0
        if (Test-Path $SnapshotPath) {
            $SnapshotSizeKB = [Math]::Round((Get-Item $SnapshotPath).Length / 1024, 1)
        }

        $TotalClaims = 0
        if ($Meta.PSObject.Properties['total_claims'] -and $null -ne $Meta.total_claims) {
            $TotalClaims = [int]$Meta.total_claims
        }

        # 1. Under-extraction
        if ($TotalClaims -lt 3 -and $SnapshotSizeKB -gt 30) {
            $Flags.Add('UNDER_EXTRACTED')
        }

        # 2. OCR artifact detection
        if (Test-Path $SnapshotPath) {
            $Lines = (Get-Content $SnapshotPath -Raw) -split "`n"
            $ShortCount = 0
            $RunBufCount = 0
            foreach ($Line in $Lines) {
                $Trimmed = $Line.Trim()
                $IsShort = ($Trimmed.Length -ge 1 -and $Trimmed.Length -le 2 -and $Trimmed -match '^\S{1,2}$')
                $IsBlank = ($Trimmed.Length -eq 0)
                if ($IsShort -or ($IsBlank -and $RunBufCount -gt 0)) {
                    $RunBufCount++
                    if ($IsShort) { $ShortCount++ }
                } else {
                    if ($ShortCount -ge 10) {
                        $Flags.Add('CORRUPTED')
                        break
                    }
                    $RunBufCount = 0
                    $ShortCount = 0
                }
            }
            if ($ShortCount -ge 10 -and -not $Flags.Contains('CORRUPTED')) {
                $Flags.Add('CORRUPTED')
            }
        }

        # 3. Date anomalies
        $DatePublished = $null
        if ($Meta.PSObject.Properties['date_published'] -and $Meta.date_published) {
            $DatePublished = $Meta.date_published
            [datetime]$ParsedDate = [datetime]::MinValue
            if ([datetime]::TryParse($DatePublished, [ref]$ParsedDate)) {
                [datetime]$DateIngested = [datetime]::MinValue
                $HasIngested = $false
                if ($Meta.PSObject.Properties['date_ingested'] -and $Meta.date_ingested) {
                    $HasIngested = [datetime]::TryParse($Meta.date_ingested, [ref]$DateIngested)
                }
                $RefDate = if ($HasIngested) { $DateIngested } else { Get-Date }
                if (($ParsedDate - $RefDate).Days -gt 30) {
                    $Flags.Add('FUTURE_DATE')
                }
                if ($ParsedDate.Month -eq 1 -and $ParsedDate.Day -eq 1) {
                    $Flags.Add('GENERIC_DATE')
                }
            }
        }

        # 4. POV inconsistency
        $PovRefKey = if ($Meta.PSObject.Properties['node_references_by_pov']) { 'node_references_by_pov' } elseif ($Meta.PSObject.Properties['claims_by_pov']) { 'claims_by_pov' } else { $null }
        if ($Meta.PSObject.Properties['pov_tags'] -and $Meta.pov_tags -and
            $PovRefKey -and $Meta.$PovRefKey) {
            foreach ($Pov in @($Meta.pov_tags)) {
                if ($null -eq $Pov) { continue }
                if ($Meta.$PovRefKey.PSObject.Properties[$Pov] -and [int]$Meta.$PovRefKey.$Pov -eq 0) {
                    $Flags.Add('POV_MISMATCH')
                    break
                }
            }
        }

        # 5. Density metric
        $ClaimsPerKB = 0
        if ($SnapshotSizeKB -gt 0) {
            $ClaimsPerKB = [Math]::Round($TotalClaims / $SnapshotSizeKB, 2)
        }

        # 6. Quality tier
        $Tier = if ($Flags.Contains('UNDER_EXTRACTED') -or $Flags.Contains('CORRUPTED')) {
            'Failed'
        } elseif ($TotalClaims -lt 3) {
            'Failed'
        } elseif ($TotalClaims -lt 5 -or $Flags.Count -gt 0) {
            'Fair'
        } else {
            'Good'
        }

        $Results.Add([PSCustomObject]@{
            DocId       = $Dir.Name
            Claims      = $TotalClaims
            SnapshotKB  = $SnapshotSizeKB
            ClaimsPerKB = $ClaimsPerKB
            Tier        = $Tier
            Flags       = if ($Flags.Count -gt 0) { $Flags -join ', ' } else { '' }
        })
    }

    if ($Results.Count -eq 0) {
        Write-Host "`n No documents matched the filter criteria." -ForegroundColor Yellow
        return
    }

    if ($PassThru) { return $Results.ToArray() }

    # Display
    $TierCounts = @{ Good = 0; Fair = 0; Failed = 0 }
    foreach ($R in $Results) { $TierCounts[$R.Tier]++ }

    Write-Host "`n IMPORT QUALITY REPORT ($($Results.Count) documents)" -ForegroundColor White
    Write-Host " $('─' * 95)" -ForegroundColor DarkGray
    Write-Host (' {0,-55} {1,6} {2,8} {3,7} {4,-20}' -f 'Document', 'Claims', 'Size KB', 'Tier', 'Flags') -ForegroundColor Cyan

    foreach ($R in ($Results | Sort-Object Tier, DocId)) {
        $TierColor = switch ($R.Tier) {
            'Good'   { 'Green'  }
            'Fair'   { 'Yellow' }
            'Failed' { 'Red'    }
        }
        $Abbrev = if ($R.DocId.Length -gt 53) { $R.DocId.Substring(0, 50) + '...' } else { $R.DocId }
        Write-Host (' {0,-55} {1,6} {2,8} ' -f $Abbrev, $R.Claims, $R.SnapshotKB) -ForegroundColor Gray -NoNewline
        Write-Host ('{0,7} ' -f $R.Tier) -ForegroundColor $TierColor -NoNewline
        if ($R.Flags) {
            Write-Host $R.Flags -ForegroundColor DarkYellow
        } else {
            Write-Host ''
        }
    }

    Write-Host " $('─' * 95)" -ForegroundColor DarkGray
    Write-Host -NoNewline ' Summary: '
    Write-Host -NoNewline "$($TierCounts['Good']) Good" -ForegroundColor Green
    Write-Host -NoNewline ' | '
    Write-Host -NoNewline "$($TierCounts['Fair']) Fair" -ForegroundColor Yellow
    Write-Host -NoNewline ' | '
    Write-Host "$($TierCounts['Failed']) Failed" -ForegroundColor Red
    Write-Host ''
}