bcquality/tools/Get-KnowledgeArticles.ps1
Jesper Schulz-Wedde 6a89831c5c Add bounded knowledge retrieval
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af
2026-09-11 11:46:01 +02:00

187 lines
7.3 KiB
PowerShell

<#
.SYNOPSIS
Reads a bounded prefix of exact article or sample paths without altering bodies.
.DESCRIPTION
The UTF-8 byte size bound covers the complete serialized JSON plus its output
newline. A body that cannot fit fails explicitly; it is never summarized or
truncated. Samples are loaded only with -Samples and must be linked by their
sibling article using READ's exact link convention.
#>
#requires -Version 7.2
[CmdletBinding()]
param(
[ValidateNotNullOrEmpty()] [string] $BCQualityRoot = (Split-Path $PSScriptRoot -Parent),
[Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Paths,
[ValidateRange(1, 8)] [int] $MaxArticles = 8,
[ValidateRange(1024, 16000)] [int] $MaxBytes = 16000,
[ValidateSet('microsoft', 'community', 'custom')]
[AllowEmptyCollection()] [string[]] $EnabledLayers = @('microsoft', 'community', 'custom'),
[string] $IndexPath,
[ValidatePattern('^[a-f0-9]{64}$')] [string] $Snapshot,
[switch] $Samples
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Knowledge-Retrieval.ps1')
. (Join-Path $PSScriptRoot 'Bounded-Results.ps1')
$BCQualityRoot = Resolve-KnowledgeRoot $BCQualityRoot
if (-not $IndexPath) {
$IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
}
if ($Paths.Count -gt $MaxArticles) {
throw "Paths count $($Paths.Count) exceeds MaxArticles=$MaxArticles. Split the worklist into stable chunks of at most $MaxArticles exact paths."
}
if ($null -eq $EnabledLayers) {
throw 'EnabledLayers must be an array.'
}
if (@($EnabledLayers | Where-Object { $_ -cnotin @('microsoft', 'community', 'custom') }).Count -or
@($EnabledLayers | Group-Object -CaseSensitive | Where-Object Count -gt 1).Count) {
throw 'EnabledLayers must contain unique canonical lowercase layer names.'
}
$recovery = "Run Entry preparation once before dispatch, or use READ's bounded native-file fallback. Do not rebuild in a leaf."
$preparedIndex = Read-PreparedKnowledgeIndex -IndexPath $IndexPath -Recovery $recovery
$index = $preparedIndex.index
$byPath = $preparedIndex.byPath
$unrestricted = $index.enabledLayers.Count -eq 0 -or
($index.enabledLayers.Count -eq 1 -and $null -eq $index.enabledLayers[0])
$indexedLayers = @(
if ($unrestricted) { 'microsoft', 'community', 'custom' } else { $index.enabledLayers }
)
if (@($EnabledLayers | Where-Object { $_ -cnotin $indexedLayers }).Count) {
throw "Index layer coverage does not cover EnabledLayers. $recovery"
}
$resolved = [Collections.Generic.List[string]]::new()
$records = [Collections.Generic.List[object]]::new()
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$articleTexts = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal)
$sampleContents = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal)
foreach ($path in $Paths) {
$kind = if ($Samples) { 'sample' } else { 'article' }
$fullPath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $path -Kind $kind
if ($path.Split('/')[0] -cnotin $EnabledLayers) {
throw "Layer disabled for path: $path"
}
if (-not $seen.Add($path)) {
throw "Duplicate requested path: $path"
}
if ($Samples) {
$articlePath = $path -replace '\.(good|bad)\.[a-z0-9]+$', '.md'
if (-not $byPath.ContainsKey($articlePath)) {
throw "Sample article is absent from the prepared index: $articlePath"
}
$fullArticlePath = Resolve-KnowledgePath -Root $BCQualityRoot -Path $articlePath
if (-not $articleTexts.ContainsKey($articlePath)) {
$articleContent = Read-KnowledgeText -Path $fullArticlePath
if ($articleContent.sha256 -cne $byPath[$articlePath].sourceSha256) {
throw "Selected article hash does not match the prepared index: $articlePath"
}
$articleTexts.Add($articlePath, $articleContent.text)
}
Assert-SampleLink -ArticleText $articleTexts[$articlePath] -SamplePath $fullPath
$sampleContent = Read-KnowledgeText -Path $fullPath
$sampleContents.Add($path, $sampleContent)
$records.Add([ordered]@{
path = $path
articlePath = $articlePath
articleSha256 = $byPath[$articlePath].sourceSha256
sampleSha256 = $sampleContent.sha256
sampleBytes = $sampleContent.bytes
})
}
else {
if (-not $byPath.ContainsKey($path)) {
throw "Selected article is absent from the prepared index: $path"
}
$records.Add([ordered]@{
path = $path
expectedSha256 = $byPath[$path].sourceSha256
})
}
$resolved.Add($fullPath)
}
$requestSnapshot = Get-ResultSnapshot -Value ([ordered]@{
root = $BCQualityRoot
preparedIndexSha256 = $preparedIndex.content.sha256
kind = if ($Samples) { 'samples' } else { 'articles' }
enabledLayers = @($EnabledLayers)
files = @($records)
})
if ($Snapshot -and $Snapshot -cne $requestSnapshot) {
throw 'Article snapshot changed or continuation belongs to another exact path batch. Discard partial results and restart.'
}
$articles = [Collections.Generic.List[object]]::new()
function ConvertTo-BatchJson {
param([int] $ReadCount)
$remaining = @(
if ($ReadCount -lt $Paths.Count) {
$Paths[$ReadCount..($Paths.Count - 1)]
}
)
$remainingRecords = @(
if ($ReadCount -lt $records.Count) {
$records[$ReadCount..($records.Count - 1)]
}
)
$continuation = if ($remaining.Count) {
[ordered]@{
snapshot = Get-ResultSnapshot -Value ([ordered]@{
root = $BCQualityRoot
preparedIndexSha256 = $preparedIndex.content.sha256
kind = if ($Samples) { 'samples' } else { 'articles' }
enabledLayers = @($EnabledLayers)
files = $remainingRecords
})
}
}
else {
$null
}
return [ordered]@{
version = 1
kind = if ($Samples) { 'samples' } else { 'articles' }
snapshot = $requestSnapshot
requestedCount = $Paths.Count
returnedCount = $ReadCount
complete = ($ReadCount -eq $Paths.Count)
articles = @($articles)
remainingPaths = $remaining
continuation = $continuation
} | ConvertTo-Json -Depth 8 -Compress
}
$json = ''
for ($i = 0; $i -lt [Math]::Min($MaxArticles, $Paths.Count); $i++) {
$content = if ($Samples) {
$sampleContents[$Paths[$i]]
}
else {
Read-KnowledgeText -Path $resolved[$i]
}
if (-not $Samples -and $content.sha256 -cne $records[$i].expectedSha256) {
throw "Selected article hash does not match the prepared index: $($Paths[$i])"
}
$articles.Add([ordered]@{
path = $Paths[$i]
bytes = $content.bytes
sha256 = $content.sha256
body = $content.text
})
$next = ConvertTo-BatchJson -ReadCount ($i + 1)
if ((Get-SerializedByteCount -Json $next) -gt $MaxBytes) {
$articles.RemoveAt($articles.Count - 1)
if ($i -eq 0) {
throw "No complete body plus continuation fits MaxBytes=$MaxBytes at $($Paths[$i]). Use a smaller exact path batch or READ's bounded native-file fallback; never truncate."
}
break
}
$json = $next
}
$json