mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
Own the knowledge-index generator + index-aware review skills (#25)
* Make domain-skill knowledge discovery index-aware The 6 AL domain review skills and read.md now enumerate candidate articles from the BCQuality knowledge index (knowledge-index.json) instead of opening every file under the domain folder to read its frontmatter. The worklist selection predicate is unchanged (keywords intersect diff tokens, or topic matches a changed object type) - only the discovery source changes, so the same articles are selected. Full article bodies are read only for worklisted entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reconcile §Source wording with the lean knowledge index The BCQuality filter now emits a lean index whose per-article description is a one-line hint rather than the full verbatim Description. Update the six domain skills' §Source to say the index carries a one-line description hint (keywords, title, and a one-line description) instead of the full description. The worklist selection predicate is unchanged: keywords drive selection and the agent opens worklisted articles in full for their rule bodies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Own the knowledge-index generator in BCQuality The knowledge index is an acceleration of the skills' Source step, and its schema is part of that contract — so BCQuality should own the generator rather than each consumer re-implementing it. Add tools/Build-KnowledgeIndex.ps1 (the parser + lean-description shaping + emit, lifted verbatim from the BCAppsBCQuality filter prototype) and document the index in agent-consumption.md. Consumers prune their clone to policy, then call this script; the index stays in lockstep with the Source contract and every orchestrator gets the same faithful index for free. The worklist selection predicate is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Own knowledge-index generation in BCQuality (runtime + CI), not the consumer The index is now produced by BCQuality itself: Entry's preparation step rebuilds knowledge-index.json over the live, already-pruned clone at the start of every run, and a new CI workflow validates the generator's health (determinism, full coverage, selection-input integrity). Consumers no longer invoke or know about the index. Rebuilding over the pruned clone (vs shipping a committed full-corpus index) keeps the index exact for any consumer policy: it can never list a denied article, so policy-excluded rules cannot leak into discovery. READ now states the index is discovery-only -- a finding must cite an article opened in full, and rows whose file is absent are discarded before ranking. - skills/entry.md: new 'Preparation -- knowledge index' precondition - skills/read.md: index ownership + discovery-only invariant - microsoft/skills/review/*.md (6): 'BCQuality builds' (not 'the filter emits') - agent-consumption.md 5a: runtime+CI ownership rationale - .github/workflows/knowledge-index.yml + scripts/Test-KnowledgeIndex.ps1: generator guard - .gitignore: never commit the runtime index Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make runtime index build non-interactive and self-contained entry.md now gives the exact build command (pwsh ./tools/Build-KnowledgeIndex.ps1) so the agent's preparation step is unambiguous, and the generator's -BCQualityRoot parameter is optional (defaults to the clone root) so it runs in non-interactive -p mode without prompting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Resolve knowledge-index root to absolute path (cross-platform fix) Get-ChildItem.FullName is always absolute, so deriving the relative article path via Substring(\.Length) requires an absolute root. A relative root such as '.' (used by the CI guard's 'Test-KnowledgeIndex.ps1 -Root .') left the full path almost intact on Linux, producing bogus 'home/runner/.../knowledge' paths and failing the coverage check. Normalise both the generator's -BCQualityRoot and the test's -Root with Resolve-Path before use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jesper Schulz-Wedde <jesper.schulzwedde@microsoft.com>
This commit is contained in:
parent
b19889ec46
commit
822cae1b27
13 changed files with 402 additions and 13 deletions
245
tools/Build-KnowledgeIndex.ps1
Normal file
245
tools/Build-KnowledgeIndex.ps1
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Builds the BCQuality knowledge index — the discovery artifact the review
|
||||
skills' §Source step consumes.
|
||||
|
||||
.DESCRIPTION
|
||||
BCQuality owns the knowledge-index contract: the index schema is part of
|
||||
the Source step that the action skills (e.g. al-*-review.md) declare, so
|
||||
the generator lives here, next to the skills and knowledge it derives from.
|
||||
Consumers (orchestrators such as the BCAppsBCQuality PR-review filter) call
|
||||
this script instead of re-implementing the parser, so every consumer gets
|
||||
the same faithful index for free and the index stays in lockstep with the
|
||||
skill contract.
|
||||
|
||||
The index lets the agent enumerate candidate articles and compute the
|
||||
keyword/topic worklist overlap by reading ONE file, instead of opening
|
||||
every file under `*/knowledge/<domain>/**` just to read its frontmatter.
|
||||
The worklist SELECTION predicate is unchanged: the index carries the same
|
||||
inputs the predicate already reads (keywords + frontmatter dimensions +
|
||||
domain + path + title), and the agent still opens each worklisted article
|
||||
in full for its `## Best Practice` / `## Anti Pattern` rule bodies.
|
||||
|
||||
The index is LEAN by default: the verbatim Description is trimmed to a
|
||||
one-line hint and the JSON is emitted compact, so the index prefix the
|
||||
agent replays across passes stays small. The selection inputs remain
|
||||
lossless. Pass -FullIndex for the verbatim, pretty-printed variant.
|
||||
|
||||
This script does NOT apply layer/allow-deny policy by reading config — it
|
||||
indexes whatever knowledge files are present on disk (a consumer is
|
||||
expected to prune its clone to policy first). For provenance and to
|
||||
reproduce a consumer's exact view, pass -EnabledLayers to restrict the walk
|
||||
to those layers and to record the policy in the index header.
|
||||
|
||||
.PARAMETER BCQualityRoot
|
||||
Path to the BCQuality content root to index (typically a filtered clone).
|
||||
Defaults to the clone root (the parent of this script's `tools/` folder), so
|
||||
the agent can run `pwsh ./tools/Build-KnowledgeIndex.ps1` from the clone root
|
||||
with no arguments.
|
||||
|
||||
.PARAMETER IndexPath
|
||||
Where to write the index JSON. Defaults to `<BCQualityRoot>/knowledge-index.json`.
|
||||
|
||||
.PARAMETER EnabledLayers
|
||||
Optional layer allowlist (e.g. microsoft, community, custom). When provided,
|
||||
only those layers are walked and the value is recorded in the index header.
|
||||
Omit to index every layer present on disk.
|
||||
|
||||
.PARAMETER KnowledgeAllow
|
||||
Optional allow globs to record in the index header (provenance only).
|
||||
|
||||
.PARAMETER KnowledgeDeny
|
||||
Optional deny globs to record in the index header (provenance only).
|
||||
|
||||
.PARAMETER FullIndex
|
||||
Emit the verbatim-Description, pretty-printed index instead of the lean one.
|
||||
|
||||
.OUTPUTS
|
||||
Returns the number of articles written to the index.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BCQualityRoot,
|
||||
[string] $IndexPath,
|
||||
[string[]] $EnabledLayers,
|
||||
[string[]] $KnowledgeAllow = @(),
|
||||
[string[]] $KnowledgeDeny = @(),
|
||||
[switch] $FullIndex
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Default to the clone root (parent of this script's tools/ folder) so the
|
||||
# agent's Entry preparation step can invoke this with no arguments from the
|
||||
# checkout root. A consumer/orchestrator may still pass -BCQualityRoot.
|
||||
if (-not $BCQualityRoot) {
|
||||
$BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
}
|
||||
if (-not (Test-Path $BCQualityRoot)) {
|
||||
throw "BCQuality root not found: $BCQualityRoot"
|
||||
}
|
||||
# Normalise to an absolute path: Get-ChildItem.FullName below is always
|
||||
# absolute, so Get-RelativePath's Substring needs an absolute root to strip.
|
||||
# A relative root (e.g. '.') would otherwise leave the full path intact.
|
||||
$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path
|
||||
if (-not $IndexPath) {
|
||||
$IndexPath = Join-Path $BCQualityRoot 'knowledge-index.json'
|
||||
}
|
||||
|
||||
function Get-RelativePath {
|
||||
param([string] $Root, [string] $Full)
|
||||
$rel = $Full.Substring($Root.Length).TrimStart([char]'/', [char]'\')
|
||||
return ($rel -replace '\\', '/')
|
||||
}
|
||||
|
||||
# Trims a Description to a single short line (<= $Max chars) for the lean
|
||||
# index. Takes the first sentence; truncates on a word boundary if still long.
|
||||
function Get-LeanDescription {
|
||||
param([string] $Text, [int] $Max = 120)
|
||||
if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
|
||||
$t = ($Text -replace '\s+', ' ').Trim()
|
||||
$m = [regex]::Match($t, '^(.*?[\.!?])(\s|$)')
|
||||
if ($m.Success) { $t = $m.Groups[1].Value.Trim() }
|
||||
if ($t.Length -le $Max) { return $t }
|
||||
$cut = $t.Substring(0, $Max)
|
||||
$sp = $cut.LastIndexOf(' ')
|
||||
if ($sp -gt 40) { $cut = $cut.Substring(0, $sp) }
|
||||
return ($cut.TrimEnd() + '…')
|
||||
}
|
||||
|
||||
function ConvertFrom-ArticleFrontmatter {
|
||||
# Parses a knowledge file into the fields the knowledge index needs.
|
||||
# Captures the verbatim frontmatter dimensions/keywords, the H1 title,
|
||||
# and the full Description section (the article's primary retrieval
|
||||
# target per READ). No rule-body content (## Best Practice / ## Anti
|
||||
# Pattern) is included; the index is a lossless substitute for the
|
||||
# frontmatter + Description the worklist predicate reads, not a
|
||||
# substitute for the article's normative guidance.
|
||||
param([string] $Path)
|
||||
|
||||
$lines = Get-Content -LiteralPath $Path -ErrorAction Stop
|
||||
|
||||
# Frontmatter is the first '---'-delimited block.
|
||||
if ($lines.Count -lt 1 -or $lines[0].Trim() -ne '---') { return $null }
|
||||
$fmEnd = -1
|
||||
for ($i = 1; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i].Trim() -eq '---') { $fmEnd = $i; break }
|
||||
}
|
||||
if ($fmEnd -lt 0) { return $null }
|
||||
|
||||
$fm = @{}
|
||||
for ($i = 1; $i -lt $fmEnd; $i++) {
|
||||
$line = $lines[$i]
|
||||
if ($line -match '^\s*([a-zA-Z][\w-]*)\s*:\s*(.*)$') {
|
||||
$key = $Matches[1]
|
||||
$val = $Matches[2].Trim()
|
||||
if ($val -match '^\[(.*)\]$') {
|
||||
$inner = $Matches[1].Trim()
|
||||
if ($inner -eq '') { $fm[$key] = @() }
|
||||
else { $fm[$key] = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) }
|
||||
}
|
||||
elseif ($val -ne '') { $fm[$key] = $val }
|
||||
}
|
||||
}
|
||||
|
||||
# Body parsing: H1 title and the full Description section. The Description
|
||||
# is the article's primary retrieval target per READ and is captured
|
||||
# verbatim (it carries no rule-body guidance and no fenced code per the
|
||||
# schema), so the index is a lossless substitute for the frontmatter +
|
||||
# Description that the worklist predicate reads. Normative rule bodies
|
||||
# (## Best Practice / ## Anti Pattern) are deliberately NOT included.
|
||||
$title = ''
|
||||
$description = ''
|
||||
$inDescription = $false
|
||||
$descBuffer = [System.Collections.Generic.List[string]]::new()
|
||||
for ($i = $fmEnd + 1; $i -lt $lines.Count; $i++) {
|
||||
$line = $lines[$i]
|
||||
if (-not $title -and $line -match '^\#\s+(.+?)\s*$') { $title = $Matches[1].Trim(); continue }
|
||||
if ($line -match '^\#\#\s+Description\s*$') { $inDescription = $true; continue }
|
||||
if ($inDescription) {
|
||||
if ($line -match '^\#\#\s') { break } # next section ends Description
|
||||
if ($line.Trim() -ne '') { $descBuffer.Add($line.Trim()) | Out-Null }
|
||||
}
|
||||
}
|
||||
if ($descBuffer.Count -gt 0) { $description = ($descBuffer -join ' ').Trim() }
|
||||
|
||||
return [pscustomobject]@{
|
||||
domain = if ($fm.ContainsKey('domain')) { [string]$fm['domain'] } else { '' }
|
||||
'bc-version' = @($fm['bc-version'])
|
||||
technologies = @($fm['technologies'])
|
||||
countries = @($fm['countries'])
|
||||
'application-area'= @($fm['application-area'])
|
||||
keywords = @($fm['keywords'])
|
||||
title = $title
|
||||
description = $description
|
||||
}
|
||||
}
|
||||
|
||||
# Walk the knowledge files and emit a single compact discovery artifact so
|
||||
# consumers can enumerate candidate articles and compute keyword/topic worklist
|
||||
# overlap without opening every file. When -EnabledLayers is supplied the walk
|
||||
# is restricted to those layers (a consumer reproduces its filtered view); the
|
||||
# article set otherwise reflects whatever is present on disk.
|
||||
$indexArticles = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($layerDir in @('microsoft', 'community', 'custom')) {
|
||||
$kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge')
|
||||
if (-not (Test-Path $kbRoot)) { continue }
|
||||
if ($EnabledLayers -and ($EnabledLayers -notcontains $layerDir)) { continue }
|
||||
|
||||
Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue |
|
||||
Sort-Object FullName |
|
||||
ForEach-Object {
|
||||
$rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName
|
||||
$parsed = $null
|
||||
try { $parsed = ConvertFrom-ArticleFrontmatter -Path $_.FullName } catch { $parsed = $null }
|
||||
if (-not $parsed) {
|
||||
# Invalid/unparseable file: list path + domain-from-path so it
|
||||
# is never silently dropped from discovery. Consumers fall back
|
||||
# to reading it in full.
|
||||
$domainFromPath = if ($rel -match '/knowledge/([^/]+)/') { $Matches[1] } else { '' }
|
||||
$indexArticles.Add([pscustomobject]@{
|
||||
path = $rel; layer = $layerDir; domain = $domainFromPath
|
||||
'bc-version' = @(); technologies = @(); countries = @(); 'application-area' = @()
|
||||
keywords = @(); title = ''; description = ''; parsed = $false
|
||||
}) | Out-Null
|
||||
return
|
||||
}
|
||||
$indexArticles.Add([pscustomobject]@{
|
||||
path = $rel
|
||||
layer = $layerDir
|
||||
domain = $parsed.domain
|
||||
'bc-version' = @($parsed.'bc-version')
|
||||
technologies = @($parsed.technologies)
|
||||
countries = @($parsed.countries)
|
||||
'application-area' = @($parsed.'application-area')
|
||||
keywords = @($parsed.keywords)
|
||||
title = $parsed.title
|
||||
description = if ($FullIndex) { $parsed.description } else { Get-LeanDescription -Text $parsed.description }
|
||||
parsed = $true
|
||||
}) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$index = [pscustomobject]@{
|
||||
version = 1
|
||||
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
enabledLayers = @($EnabledLayers)
|
||||
knowledgeAllow= @($KnowledgeAllow)
|
||||
knowledgeDeny = @($KnowledgeDeny)
|
||||
articleCount = $indexArticles.Count
|
||||
articles = @($indexArticles)
|
||||
}
|
||||
|
||||
$indexDir = Split-Path -Parent $IndexPath
|
||||
if ($indexDir -and -not (Test-Path $indexDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $indexDir | Out-Null
|
||||
}
|
||||
if ($FullIndex) {
|
||||
$index | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $IndexPath -Encoding UTF8
|
||||
} else {
|
||||
$index | ConvertTo-Json -Depth 8 -Compress | Set-Content -LiteralPath $IndexPath -Encoding UTF8
|
||||
}
|
||||
|
||||
Write-Host "BCQuality index: $($indexArticles.Count) article(s). Index: $IndexPath"
|
||||
return $indexArticles.Count
|
||||
Loading…
Add table
Add a link
Reference in a new issue