bcquality/tools/Get-KnowledgeArticles.ps1
Jesper Schulz-Wedde 51597068b1
Add bounded knowledge retrieval (#179)
* Add bounded knowledge retrieval

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af

* Fix bc-version overflow and pathless-row identity in bounded retrieval

Catalog matching compared an Int32 -BCVersion against a bigint range bound.
PowerShell coerces the right operand to the left operand's type, so a bound
wider than Int32 threw a conversion error and failed the whole domain catalog
rather than the single row. Metadata validation already accepts such bounds,
so compare as bigint on both sides.

The shared pager built its oversized-row message with $row.path, which
throws under Set-StrictMode -Version Latest when a row carries no path,
replacing the explicit bound failure with a property-lookup error. Resolve the
path defensively for dictionary and object rows so the offset-based fallback
is reachable.

Both paths gain regression coverage that fails without these fixes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0d8b7764-f15a-49ea-8d50-d9147334f8af
2026-09-11 12:35:31 +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