Public/Import-AITriadDocument.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Import-AITriadDocument { <# .SYNOPSIS AI Triad document ingestion. .DESCRIPTION Ingests a document into the AI Triad repository. What this function does: 1. Generates a stable doc-id slug from the title/URL. 2. Creates sources/<doc-id>/raw/ and saves the original file. 3. Converts to Markdown snapshot (sources/<doc-id>/snapshot.md). 4. Creates sources/<doc-id>/metadata.json with summary_status: pending. 5. Optionally triggers Wayback Machine save (fire-and-forget). 6. Returns the doc-id for use in follow-up commands. .PARAMETER Url URL of web article to ingest. .PARAMETER File Path to a local PDF/DOCX/HTML file to ingest. .PARAMETER Inbox Process all files in sources/_inbox/. .PARAMETER Pov One or more POV tags: accelerationist, safetyist, skeptic, cross-cutting. .PARAMETER Topic One or more topic tags. .PARAMETER SkipWayback Skip the Wayback Machine archival submission. .PARAMETER NoSummaryQueue Do not mark the document for AI summarisation. .PARAMETER Force Allow re-ingesting a document that already exists in the corpus. .PARAMETER SkipAiMeta Skip the AI metadata-enrichment step. .PARAMETER Model AI model to use for metadata enrichment and summarization. Supports Gemini, Claude, and Groq backends. Default: gemini-3.5-flash-lite .PARAMETER Temperature Sampling temperature (0.0-1.0) passed to summarization. Lower values produce more deterministic output. Default: 0.1 .EXAMPLE Import-AITriadDocument -Url 'https://example.com/article' -Pov accelerationist, skeptic .EXAMPLE Import-AITriadDocument -Inbox .EXAMPLE Import-AITriadDocument -File 'path/to/file.pdf' -Pov skeptic .NOTES Set backend-specific env vars (GEMINI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY) or AI_API_KEY for metadata enrichment. .LINK Show-AITriadHelp .LINK Save-AITSource .LINK Find-AITSource .LINK Get-AITSource .LINK Update-AITSourceIndex .LINK Get-IngestionPriority .LINK Get-ImportReport #> [CmdletBinding(DefaultParameterSetName = 'ByUrl')] param( [Parameter(ParameterSetName = 'ByUrl', Mandatory)] [string]$Url, [Parameter(ParameterSetName = 'ByFile', Mandatory)] [ValidateScript({ Test-Path $_ })] [string]$File, [Parameter(ParameterSetName = 'ByInbox', Mandatory)] [switch]$Inbox, [Parameter(ParameterSetName = 'ByUrl')] [Parameter(ParameterSetName = 'ByFile')] [ValidateSet('accelerationist', 'safetyist', 'skeptic', 'cross-cutting', 'situations')] [string[]]$Pov = @(), [Parameter(ParameterSetName = 'ByUrl')] [Parameter(ParameterSetName = 'ByFile')] [string[]]$Topic = @(), [switch]$SkipWayback, [switch]$NoSummaryQueue, [switch]$SkipAiMeta, [switch]$Force, [switch]$NoSummarize, [ValidateScript({ Test-AIModelId $_ })] [ArgumentCompleter({ param($cmd, $param, $word) $script:ValidModelIds | Where-Object { $_ -like "$word*" } })] [Alias('GeminiModel')] [string]$Model = 'gemini-3.5-flash-lite', [ValidateRange(0.0, 1.0)] [double]$Temperature = 0.1 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # -- Paths ---------------------------------------------------------------- $SourcesDir = Get-SourcesDir $InboxDir = Join-Path $SourcesDir '_inbox' # -- AI API key (read once; absence is non-fatal) ------------------------- if ($Model -match '^gemini') { $Backend = 'gemini' } elseif ($Model -match '^claude') { $Backend = 'claude' } elseif ($Model -match '^groq') { $Backend = 'groq' } elseif ($Model -match '^openai') { $Backend = 'openai' } else { $Backend = 'gemini' } $AIApiKey = Resolve-AIApiKey -ExplicitKey '' -Backend $Backend # ========================================================================= # Inner function — called once per document # ========================================================================= function Find-ExistingSource { param( [string]$Url, [string]$FilePath ) $MetaFiles = @(Get-ChildItem -Path $SourcesDir -Filter 'metadata.json' -Recurse -Depth 1 -ErrorAction SilentlyContinue) foreach ($MF in $MetaFiles) { try { $Meta = Get-Content $MF.FullName -Raw | ConvertFrom-Json } catch { continue } if (-not [string]::IsNullOrWhiteSpace($Url) -and $Meta.url -eq $Url) { return $MF.Directory.Name } if (-not [string]::IsNullOrWhiteSpace($FilePath)) { $FileName = [System.IO.Path]::GetFileName($FilePath) $RawDir = Join-Path $MF.Directory.FullName 'raw' if (Test-Path (Join-Path $RawDir $FileName)) { return $MF.Directory.Name } } } return $null } function Invoke-IngestDocument { [CmdletBinding()] param( [string] $SourceUrl = '', [string] $SourceFile = '', [string[]]$PovTags = @(), [string[]]$TopicTags = @() ) # -- Duplicate check ------------------------------------------------- if (-not $Force) { $ExistingDocId = Find-ExistingSource -Url $SourceUrl -FilePath $SourceFile if ($ExistingDocId) { $MatchType = if (-not [string]::IsNullOrWhiteSpace($SourceUrl)) { "URL '$SourceUrl'" } else { "file '$(Split-Path $SourceFile -Leaf)'" } throw (New-ActionableError ` -Goal "Ingest document" ` -Problem "Duplicate source: $MatchType already ingested as '$ExistingDocId'" ` -Location 'Import-AITriadDocument' ` -NextSteps @( "To re-ingest, use: Import-AITriadDocument ... -Force" "To view existing source: Get-AITSource -DocId '$ExistingDocId'" )) } } $RawContent = $null $MarkdownText = '' $Title = '' $Authors = @() $SourceType = 'unknown' $RawExtension = '' $IsUrl = -not [string]::IsNullOrWhiteSpace($SourceUrl) if ($IsUrl) { # -- URL ingestion ------------------------------------------------ Write-Step "Fetching URL: $SourceUrl" $SourceType = 'web_article' $RawExtension = '.html' try { $TempHtml = [System.IO.Path]::GetTempFileName() + '.html' $WebHeaders = @{ 'User-Agent' = 'Mozilla/5.0 (AI Triad Research Bot; +https://cyber.harvard.edu)' } Invoke-RestMethod -Uri $SourceUrl -OutFile $TempHtml -TimeoutSec 30 -Headers $WebHeaders -ErrorAction Stop $HtmlContent = Get-Content -Path $TempHtml -Raw -Encoding UTF8 Remove-Item $TempHtml -Force -ErrorAction SilentlyContinue Write-OK "Fetched $([int]$HtmlContent.Length) characters" } catch { Remove-Item $TempHtml -Force -ErrorAction SilentlyContinue Write-Fail "Failed to fetch URL: $_" throw } $Meta = Get-HtmlMeta -Html $HtmlContent if ($Meta.Title) { $Title = $Meta.Title } else { $Title = $SourceUrl } $Authors = $Meta.Author $MarkdownText = ConvertFrom-Html -Html $HtmlContent -SourceUrl $SourceUrl $RawContent = $HtmlContent if ($SourceUrl -match '\.pdf(\?.*)?$') { Write-Info "URL points to a PDF — attempting direct PDF download" try { $TempPdf = [System.IO.Path]::GetTempFileName() + '.pdf' Invoke-RestMethod -Uri $SourceUrl -OutFile $TempPdf -TimeoutSec 60 -ErrorAction Stop $RawContent = [System.IO.File]::ReadAllBytes($TempPdf) $MarkdownText = ConvertFrom-Pdf -PdfPath $TempPdf Remove-Item $TempPdf -Force $RawExtension = '.pdf' $SourceType = 'pdf' } catch { Write-Warn "PDF download failed, falling back to HTML content: $_" Remove-Item $TempPdf -Force -ErrorAction SilentlyContinue } } } else { # -- Local file ingestion ----------------------------------------- $ResolvedFile = Resolve-Path $SourceFile $Ext = [System.IO.Path]::GetExtension($ResolvedFile).ToLower() $RawExtension = $Ext Write-Step "Ingesting file: $ResolvedFile" $RawContent = [System.IO.File]::ReadAllBytes($ResolvedFile) switch ($Ext) { '.pdf' { $SourceType = 'pdf' $MarkdownText = ConvertFrom-Pdf -PdfPath $ResolvedFile $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' } { $_ -in '.docx', '.doc' } { $SourceType = 'docx' $MarkdownText = ConvertFrom-Docx -DocxPath $ResolvedFile $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' } { $_ -in '.html', '.htm' } { $SourceType = 'web_article' $HtmlContent = [System.IO.File]::ReadAllText($ResolvedFile) $Meta = Get-HtmlMeta -Html $HtmlContent if ($Meta.Title) { $Title = $Meta.Title } else { $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) } $Authors = $Meta.Author # Prefer markitdown for HTML (better semantic extraction) $MidMd = ConvertFrom-MarkItDown -FilePath $ResolvedFile if ($MidMd) { $MarkdownText = $MidMd } else { $MarkdownText = ConvertFrom-Html -Html $HtmlContent } } { $_ -in '.pptx', '.ppt' } { $SourceType = 'presentation' $MarkdownText = ConvertFrom-Office -FilePath $ResolvedFile $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' } { $_ -in '.xlsx', '.xls', '.csv' } { $SourceType = 'spreadsheet' $MarkdownText = ConvertFrom-Office -FilePath $ResolvedFile $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' } { $_ -in '.epub' } { $SourceType = 'ebook' $MarkdownText = ConvertFrom-Office -FilePath $ResolvedFile $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' } { $_ -in '.md', '.txt' } { if ($Ext -eq '.md') { $SourceType = 'markdown' } else { $SourceType = 'plaintext' } $MarkdownText = [System.IO.File]::ReadAllText($ResolvedFile) $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' $H1Match = [regex]::Match($MarkdownText, '(?m)^#\s+(.+)$') if ($H1Match.Success) { $Title = $H1Match.Groups[1].Value.Trim() } } Default { # Try markitdown as a last-resort universal converter $MidMd = ConvertFrom-MarkItDown -FilePath $ResolvedFile if ($MidMd) { $SourceType = 'document' $MarkdownText = $MidMd $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) -replace '[-_]', ' ' } else { Write-Warn "Unsupported file type '$Ext' — storing raw, no Markdown conversion" $SourceType = 'unknown' $MarkdownText = "# $(Split-Path $ResolvedFile -Leaf)`n`n[Binary file — no text extraction available]" $Title = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedFile) } } } Write-OK "Read $($RawContent.Length) bytes from file" } # -- Gemini metadata enrichment --------------------------------------- $AiMeta = $null if (-not $SkipAiMeta -and -not [string]::IsNullOrWhiteSpace($AIApiKey)) { try { $AiMeta = Get-AIMetadata ` -MarkdownText $MarkdownText ` -SourceUrl $SourceUrl ` -FallbackTitle $Title ` -Model $Model ` -ApiKey $AIApiKey } catch { Write-Warn "AI enrichment threw an exception — continuing with heuristics: $_" $AiMeta = $null } } elseif ($SkipAiMeta) { Write-Info "Skipping AI enrichment (-SkipAiMeta)" } else { Write-Warn "No API key found — metadata enrichment skipped. Set backend env var or AI_API_KEY." } # Merge AI results with heuristic values and user-supplied flags if ($null -ne $AiMeta) { if (-not [string]::IsNullOrWhiteSpace($AiMeta.title)) { $Title = $AiMeta.title } $AiAuthors = @($AiMeta.authors | Where-Object { $_ }) if ($AiAuthors.Count -gt 0) { $Authors = $AiAuthors } $AiPovTags = @($AiMeta.pov_tags | Where-Object { $_ }) if ($PovTags.Count -eq 0 -and $AiPovTags.Count -gt 0) { $PovTags = $AiPovTags Write-Info "POV tags from AI: $($PovTags -join ', ')" } elseif ($PovTags.Count -gt 0) { Write-Info "POV tags from -Pov flag (AI suggestion ignored): $($PovTags -join ', ')" } $MergedTopics = @(@($TopicTags) + @($AiMeta.topic_tags) | Select-Object -Unique | Where-Object { $_ }) $TopicTags = $MergedTopics if ($TopicTags.Count -gt 0) { Write-Info "Topic tags (merged): $($TopicTags -join ', ')" } } # -- Generate doc-id -------------------------------------------------- $SlugSource = $Title if ([string]::IsNullOrWhiteSpace($SlugSource)) { if ($IsUrl) { $SlugSource = $SourceUrl } else { $SlugSource = [System.IO.Path]::GetFileNameWithoutExtension($SourceFile) } } $BaseSlug = New-Slug -Text $SlugSource $DocId = Resolve-DocId -BaseSlug $BaseSlug Write-OK "Doc ID: $DocId" # -- Create directory structure --------------------------------------- $DocDir = Join-Path $SourcesDir $DocId $RawDir = Join-Path $DocDir 'raw' New-Item -ItemType Directory -Path $RawDir -Force | Out-Null Write-OK "Created: sources/$DocId/" # -- Save raw file ---------------------------------------------------- if ($IsUrl) { $RawFilename = 'original' + $RawExtension } else { $RawFilename = [System.IO.Path]::GetFileName($SourceFile) } $RawPath = Join-Path $RawDir $RawFilename if ($RawContent -is [byte[]]) { # Use .NET directly — Set-Content -AsByteStream is PS 6+ only, and # WriteAllBytes is symmetric with the ReadAllBytes used to load it. [System.IO.File]::WriteAllBytes($RawPath, $RawContent) } else { Write-Utf8NoBom -Path $RawPath -Value $RawContent } Write-OK "Raw file saved: raw/$RawFilename" # -- Normalize markdown (encoding artifacts, ligatures, etc.) ---------- $MarkdownText = Repair-Markdown -Text $MarkdownText # -- Strip vertical-text artifacts (ArXiv watermarks, rotated margin text) -- $Lines = $MarkdownText -split "`n" $CleanLines = [System.Collections.Generic.List[string]]::new($Lines.Count) $RunBuf = [System.Collections.Generic.List[int]]::new() $ShortCount = 0 for ($i = 0; $i -lt $Lines.Count; $i++) { $Trimmed = $Lines[$i].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 $RunBuf.Count -gt 0)) { $RunBuf.Add($i) if ($IsShort) { $ShortCount++ } } else { if ($ShortCount -ge 10) { Write-Info "Stripped $($RunBuf.Count) lines of vertical-text artifact (lines $($RunBuf[0]+1)-$($RunBuf[$RunBuf.Count-1]+1))" } else { foreach ($j in $RunBuf) { $CleanLines.Add($Lines[$j]) } } $CleanLines.Add($Lines[$i]) $RunBuf.Clear() $ShortCount = 0 } } if ($ShortCount -ge 10) { Write-Info "Stripped $($RunBuf.Count) lines of vertical-text artifact (lines $($RunBuf[0]+1)-$($RunBuf[$RunBuf.Count-1]+1))" } elseif ($RunBuf.Count -gt 0) { foreach ($j in $RunBuf) { $CleanLines.Add($Lines[$j]) } } $MarkdownText = $CleanLines -join "`n" # -- Add provenance header and write snapshot.md ---------------------- $FinalMarkdown = Add-SnapshotHeader ` -Markdown $MarkdownText ` -Title $Title ` -SourceUrl $SourceUrl ` -SourceType $SourceType ` -CapturedAt (Get-Date -Format 'yyyy-MM-dd') $SnapshotPath = Join-Path $DocDir 'snapshot.md' Write-Utf8NoBom -Path $SnapshotPath -Value $FinalMarkdown Write-OK "Snapshot written: snapshot.md ($([int]$FinalMarkdown.Length) chars)" # -- Write metadata.json ---------------------------------------------- $Metadata = New-Metadata ` -DocId $DocId ` -Title $Title ` -DocumentUrl $SourceUrl ` -Author $Authors ` -SourceType $SourceType ` -PovTag $PovTags ` -TopicTag $TopicTags if ($null -ne $AiMeta) { if ($AiMeta.date_published) { $ValidatedDate = $AiMeta.date_published [datetime]$ParsedDate = [datetime]::MinValue if ([datetime]::TryParse($ValidatedDate, [ref]$ParsedDate)) { $Now = Get-Date $DaysAhead = ($ParsedDate - $Now).Days if ($DaysAhead -gt 30) { Write-Warn "date_published '$ValidatedDate' is $DaysAhead days in the future — likely extraction error, clearing" $ValidatedDate = $null } elseif ($DaysAhead -gt 0) { Write-Info "Pre-print: publication date $ValidatedDate is $DaysAhead day(s) ahead of import" } if ($ParsedDate.Month -eq 1 -and $ParsedDate.Day -eq 1) { Write-Info "Generic date detected ($ValidatedDate) — may be incomplete metadata" } } if ($ValidatedDate) { $Metadata['date_published'] = $ValidatedDate $Metadata['source_time'] = $ValidatedDate } } if ($AiMeta.one_liner) { $Metadata['one_liner'] = $AiMeta.one_liner } } $MetaPath = Join-Path $DocDir 'metadata.json' $Metadata | ConvertTo-Json -Depth 5 | Write-Utf8NoBom -Path $MetaPath Write-OK "Metadata written: metadata.json" # -- Rebuild source index --------------------------------------------- try { Update-AITSourceIndex -Quiet } catch { Write-Verbose "Index rebuild skipped: $_" } # -- Summary queue ---------------------------------------------------- if (-not $NoSummaryQueue) { Add-ToSummaryQueue -DocId $DocId } # -- Wayback Machine -------------------------------------------------- if ($IsUrl -and -not $SkipWayback) { Submit-ToWaybackMachine -TargetUrl $SourceUrl } # -- Done ------------------------------------------------------------- Write-Host '' Write-Host " ════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host " Ingested: $DocId" -ForegroundColor Green Write-Host " ════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host " sources/$DocId/" -ForegroundColor White Write-Host " ├── raw/$RawFilename" -ForegroundColor Gray Write-Host " ├── snapshot.md" -ForegroundColor Gray Write-Host " └── metadata.json (summary_status: pending)" -ForegroundColor Gray Write-Host '' return $DocId } # ========================================================================= # Dispatch based on parameter set # ========================================================================= switch ($PSCmdlet.ParameterSetName) { 'ByUrl' { $DocId = Invoke-IngestDocument ` -SourceUrl $Url ` -PovTags $Pov ` -TopicTags $Topic if (-not $NoSummarize -and $DocId) { Write-Step "Running POV summarization for $DocId" Invoke-BatchSummary -DocId $DocId -Model $Model -Temperature $Temperature } } 'ByFile' { $ResolvedFile = (Resolve-Path -LiteralPath $File).Path $ResolvedInbox = if (Test-Path $InboxDir) { (Resolve-Path -LiteralPath $InboxDir).Path } else { $null } $FromInbox = $ResolvedInbox -and $ResolvedFile.StartsWith($ResolvedInbox, [System.StringComparison]::OrdinalIgnoreCase) $DocId = Invoke-IngestDocument ` -SourceFile $ResolvedFile ` -PovTags $Pov ` -TopicTags $Topic $SummarizeOk = $true if (-not $NoSummarize -and $DocId) { Write-Step "Running POV summarization for $DocId" try { Invoke-BatchSummary -DocId $DocId -Model $Model -Temperature $Temperature } catch { $SummarizeOk = $false Write-Fail "Summarization failed for $DocId`: $_" if ($FromInbox) { Write-Info "File left in inbox for retry." } throw } } # If the source lives under sources/_inbox/, clean it up after a fully # successful ingest+summarize — symmetrical with the -Inbox branch. if ($FromInbox -and $DocId -and $SummarizeOk) { $SidecarPath = $ResolvedFile + '.meta.json' if (Test-Path $ResolvedFile) { Remove-Item $ResolvedFile -Force } if (Test-Path $SidecarPath) { Remove-Item $SidecarPath -Force } Write-OK "Removed inbox copy: $(Split-Path $ResolvedFile -Leaf)" } } 'ByInbox' { if (-not (Test-Path $InboxDir)) { throw (New-ActionableError ` -Goal 'Process inbox documents' ` -Problem "Inbox directory not found: $InboxDir" ` -Location 'Import-AITriadDocument -Inbox' ` -NextSteps @( "Create the inbox directory: New-Item -ItemType Directory -Path '$InboxDir'" 'Drop files into sources/_inbox/ and re-run with -Inbox' )) } # Skip VCS placeholders, OS-metadata junk (e.g. macOS .DS_Store / # AppleDouble ._* files, Windows Thumbs.db/desktop.ini), and the # *.meta.json sidecars — none of these are documents to ingest. # Without this, .DS_Store falsely "matches" an existing source via # filename in Find-ExistingSource and gets skipped as a duplicate. $InboxJunk = @('.gitkeep', '.DS_Store', 'Thumbs.db', 'desktop.ini') $InboxFiles = @(Get-ChildItem -Path $InboxDir -File | Where-Object { $InboxJunk -notcontains $_.Name -and $_.Name -notlike '.*' -and $_.Name -notlike '*.meta.json' }) if ($InboxFiles.Count -eq 0) { Write-Warn "Inbox is empty: $InboxDir" Write-Info "Drop files into sources/_inbox/ and re-run with -Inbox" return } Write-Step "Processing $($InboxFiles.Count) file(s) from inbox" $IngestedIds = @() # Track inbox files for deferred cleanup — only remove after full pipeline succeeds $InboxCleanup = @{} # DocId → @{ File = path; Sidecar = path } foreach ($InboxFile in $InboxFiles) { Write-Host '' Write-Host " Processing: $($InboxFile.Name)" -ForegroundColor White Write-Host " $('─' * 48)" -ForegroundColor DarkGray $SidecarPath = $InboxFile.FullName + '.meta.json' $SidecarPov = @() $SidecarTopic= @() if (Test-Path $SidecarPath) { try { $Sidecar = Get-Content $SidecarPath -Raw | ConvertFrom-Json if ($Sidecar.pov_tags) { $SidecarPov = $Sidecar.pov_tags } else { $SidecarPov = @() } if ($Sidecar.topic_tags) { $SidecarTopic = $Sidecar.topic_tags } else { $SidecarTopic = @() } Write-Info "Sidecar found: pov=$($SidecarPov -join ',') topics=$($SidecarTopic -join ',')" } catch { Write-Warn "Sidecar parse failed, ignoring: $SidecarPath" } } try { $DocId = Invoke-IngestDocument ` -SourceFile $InboxFile.FullName ` -PovTags $SidecarPov ` -TopicTags $SidecarTopic $IngestedIds += $DocId $InboxCleanup[$DocId] = @{ File = $InboxFile.FullName Sidecar = $SidecarPath } } catch { Write-Fail "Failed to ingest $($InboxFile.Name): $_" Write-Verbose "Stack trace: $($_.ScriptStackTrace)" Write-Info "File left in inbox for retry." } } Write-Host '' Write-Host " Inbox complete. Ingested $($IngestedIds.Count) document(s):" -ForegroundColor Cyan foreach ($id in $IngestedIds) { Write-Host " • $id" -ForegroundColor Green } Write-Host '' if (-not $NoSummarize -and $IngestedIds.Count -gt 0) { foreach ($id in $IngestedIds) { Write-Step "Running POV summarization for $id" try { Invoke-BatchSummary -DocId $id -Model $Model -Temperature $Temperature } catch { Write-Fail "Summarization failed for $id`: $_" Write-Info "File left in inbox for retry." $null = $InboxCleanup.Remove($id) } } } # -- Remove inbox files only for fully successful documents ---------- foreach ($entry in $InboxCleanup.GetEnumerator()) { $paths = $entry.Value if (Test-Path $paths.File) { Remove-Item $paths.File -Force } if (Test-Path $paths.Sidecar) { Remove-Item $paths.Sidecar -Force } } } } } |