mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Adds an optional 'effect' to routing signals. effect: suppress encodes 'this construct is NOT a violation of the domain' (the not-a-violation articles); the orchestrator subtracts a suppressor's weighted hits from the domain score (floored at 0) and does not route to it. Threaded through Build-RoutingIndex (registry now keyed by token+domain+effect so raise/suppress on the same token don't collide), routing-index.schema.json, and R24 (effect must be raise|suppress). Back-compatible: absent effect => raise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
306 lines
15 KiB
PowerShell
306 lines
15 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Builds the BCQuality routing index — the orchestrator-facing companion to
|
|
knowledge-index.json that maps PR-diff detection signals to review domains
|
|
and the knowledge articles that back them.
|
|
|
|
.DESCRIPTION
|
|
The PR-review orchestrator used to carry a hand-written ~28-token regex
|
|
catalog ($BcqSignalCatalog) INSIDE its code (Invoke-CopilotPRReview.ps1).
|
|
That shadow catalog never read the domain/keywords front-matter the articles
|
|
already declare, so its blind spots were exactly where findings got missed.
|
|
|
|
This generator retires that shadow catalog by compiling a routing index from
|
|
CONTENT: it ingests the content-owned seed (tools/routing-seed.json, the
|
|
migrated legacy catalog, so recall >= today) and then attaches to every
|
|
signal the articles that back it, plus any article-declared `signals:`
|
|
front-matter. The result — routing-index.json — is consumed by the
|
|
orchestrator's Build-ReviewManifest behind the BCQ_INDEX_V2 flag to score
|
|
per-domain suspicion and shortlist candidate articles.
|
|
|
|
This is NOT the lean, agent-replayed knowledge-index.json. The routing index
|
|
is orchestrator-side only and is never fed into the CLI prompt, so it can be
|
|
richer without inflating the token-paid path. It is regenerated
|
|
deterministically at authoring/CI time.
|
|
|
|
Recall is complete-by-construction: every article whose (normalized) domain
|
|
matches a signal's domain is attached to that signal, so a new community
|
|
article becomes routable the moment it lands — no orchestrator edit required.
|
|
|
|
.PARAMETER BCQualityRoot
|
|
Path to the BCQuality content root to index. Defaults to the clone root
|
|
(parent of this script's tools/ folder).
|
|
|
|
.PARAMETER SeedPath
|
|
Path to the routing seed. Defaults to <this script folder>/routing-seed.json.
|
|
|
|
.PARAMETER IndexPath
|
|
Where to write the routing index JSON. Defaults to
|
|
<BCQualityRoot>/routing-index.json.
|
|
|
|
.PARAMETER EnabledLayers
|
|
Optional layer allowlist (microsoft, community, custom). When provided only
|
|
those layers are walked and the value is recorded in the index header.
|
|
|
|
.PARAMETER IncludeKeywordSignals
|
|
ALSO derive soft signals from article keywords (weight 0.5, source
|
|
'keyword'). OFF by default: keywords are lowercase-kebab and match noisily,
|
|
and the feedback data warns the agent already over-fires (precision 0.246).
|
|
Seed + explicit front-matter `signals:` are the precision path.
|
|
|
|
.PARAMETER Pretty
|
|
Emit pretty-printed JSON instead of compact.
|
|
|
|
.OUTPUTS
|
|
Returns the number of signals written to the index.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $BCQualityRoot,
|
|
[string] $SeedPath,
|
|
[string] $IndexPath,
|
|
[string[]] $EnabledLayers,
|
|
[switch] $IncludeKeywordSignals,
|
|
[switch] $Pretty
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
if (-not $BCQualityRoot) {
|
|
$BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
|
}
|
|
if (-not (Test-Path $BCQualityRoot)) { throw "BCQuality root not found: $BCQualityRoot" }
|
|
$BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path
|
|
if (-not $SeedPath) { $SeedPath = Join-Path $PSScriptRoot 'routing-seed.json' }
|
|
if (-not (Test-Path $SeedPath)) { throw "Routing seed not found: $SeedPath" }
|
|
if (-not $IndexPath) { $IndexPath = Join-Path $BCQualityRoot 'routing-index.json' }
|
|
|
|
$seed = Get-Content -LiteralPath $SeedPath -Raw | ConvertFrom-Json
|
|
$domainNorm = @{}
|
|
foreach ($p in $seed.'domain-normalization'.PSObject.Properties) { $domainNorm[$p.Name] = $p.Value }
|
|
|
|
# Normalize a front-matter domain (lowercase-hyphen) to the orchestrator's
|
|
# TitleCase taxonomy. Unknown domains pass through unchanged so nothing is lost.
|
|
function ConvertTo-CanonicalDomain {
|
|
param([string] $Domain)
|
|
if ([string]::IsNullOrWhiteSpace($Domain)) { return '' }
|
|
$d = $Domain.Trim()
|
|
if ($domainNorm.ContainsKey($d)) { return $domainNorm[$d] }
|
|
return $d
|
|
}
|
|
|
|
function Get-RelativePath {
|
|
param([string] $Root, [string] $Full)
|
|
return (($Full.Substring($Root.Length).TrimStart([char]'/', [char]'\')) -replace '\\', '/')
|
|
}
|
|
|
|
# Minimal front-matter reader for routing needs: domain (scalar), keywords
|
|
# (inline array), and the OPTIONAL signals block (inline string array, or a
|
|
# block list of `- token`/`- token: X` items). Deliberately narrow — the lean
|
|
# knowledge index owns the full parse; here we only need routing inputs.
|
|
function Read-RoutingFrontmatter {
|
|
param([string] $Path)
|
|
$lines = Get-Content -LiteralPath $Path -ErrorAction Stop
|
|
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 }
|
|
|
|
$domain = ''
|
|
$keywords = @()
|
|
$signals = [System.Collections.Generic.List[object]]::new()
|
|
|
|
for ($i = 1; $i -lt $fmEnd; $i++) {
|
|
$line = $lines[$i]
|
|
if ($line -match '^\s*domain\s*:\s*(.+?)\s*$') { $domain = $Matches[1].Trim(); continue }
|
|
if ($line -match '^\s*keywords\s*:\s*\[(.*)\]\s*$') {
|
|
$inner = $Matches[1].Trim()
|
|
if ($inner -ne '') { $keywords = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) }
|
|
continue
|
|
}
|
|
if ($line -match '^\s*signals\s*:\s*(.*)$') {
|
|
$rest = $Matches[1].Trim()
|
|
if ($rest -match '^\[(.*)\]$') {
|
|
# inline: signals: [tokenA, tokenB]
|
|
$inner = $Matches[1].Trim()
|
|
if ($inner -ne '') {
|
|
foreach ($tok in ($inner -split '\s*,\s*')) {
|
|
$t = $tok.Trim().Trim('"',"'")
|
|
if ($t) { $signals.Add($t) | Out-Null }
|
|
}
|
|
}
|
|
} else {
|
|
# block list following the key: `- token` or `- token: X` / mapping
|
|
for ($j = $i + 1; $j -lt $fmEnd; $j++) {
|
|
$bl = $lines[$j]
|
|
if ($bl -match '^\s*-\s*(.+?)\s*$') {
|
|
$item = $Matches[1].Trim()
|
|
if ($item -match '^token\s*:\s*(.+)$') {
|
|
$signals.Add(@{ token = ($Matches[1].Trim().Trim('"',"'")) }) | Out-Null
|
|
} elseif ($item -notmatch ':') {
|
|
$signals.Add(($item.Trim('"',"'"))) | Out-Null
|
|
} else {
|
|
# inline-mapping `- {token: X, pattern: Y}` — best effort
|
|
$m = [regex]::Match($item, 'token\s*:\s*([^,}\s]+)')
|
|
if ($m.Success) { $signals.Add(@{ token = $m.Groups[1].Value.Trim().Trim('"',"'") }) | Out-Null }
|
|
}
|
|
} elseif ($bl -match '^\s+\w') {
|
|
# continuation of a mapping entry (pattern:/domain:/effect:) — attach to last mapping
|
|
if ($signals.Count -gt 0 -and ($signals[$signals.Count - 1] -is [hashtable])) {
|
|
if ($bl -match '^\s*pattern\s*:\s*(.+)$') { $signals[$signals.Count - 1]['pattern'] = $Matches[1].Trim().Trim('"',"'") }
|
|
elseif ($bl -match '^\s*domain\s*:\s*(.+)$') { $signals[$signals.Count - 1]['domain'] = $Matches[1].Trim().Trim('"',"'") }
|
|
elseif ($bl -match '^\s*effect\s*:\s*(.+)$') { $signals[$signals.Count - 1]['effect'] = $Matches[1].Trim().Trim('"',"'") }
|
|
}
|
|
} else { break }
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
return [pscustomobject]@{ domain = $domain; keywords = $keywords; signals = @($signals) }
|
|
}
|
|
|
|
# ---- Walk articles ---------------------------------------------------------
|
|
$articles = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($layerDir in @('microsoft', 'community', 'custom')) {
|
|
if ($EnabledLayers -and ($EnabledLayers -notcontains $layerDir)) { continue }
|
|
$kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge')
|
|
if (-not (Test-Path $kbRoot)) { continue }
|
|
Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue |
|
|
Sort-Object FullName |
|
|
ForEach-Object {
|
|
$rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName
|
|
$fm = $null
|
|
try { $fm = Read-RoutingFrontmatter -Path $_.FullName } catch { $fm = $null }
|
|
$rawDomain = if ($fm) { $fm.domain } elseif ($rel -match '/knowledge/([^/]+)/') { $Matches[1] } else { '' }
|
|
$articles.Add([pscustomobject]@{
|
|
path = $rel
|
|
layer = $layerDir
|
|
rawDomain = $rawDomain
|
|
domain = (ConvertTo-CanonicalDomain $rawDomain)
|
|
keywords = if ($fm) { @($fm.keywords) } else { @() }
|
|
signals = if ($fm) { @($fm.signals) } else { @() }
|
|
}) | Out-Null
|
|
}
|
|
}
|
|
|
|
# ---- Build the domain -> article index (for attaching backing articles) ----
|
|
$articlesByDomain = @{}
|
|
foreach ($a in $articles) {
|
|
if (-not $a.domain) { continue }
|
|
if (-not $articlesByDomain.ContainsKey($a.domain)) { $articlesByDomain[$a.domain] = [System.Collections.Generic.List[string]]::new() }
|
|
$articlesByDomain[$a.domain].Add($a.path) | Out-Null
|
|
}
|
|
|
|
# Signal registry keyed by token so seed + article-declared signals merge and
|
|
# never duplicate; articles accumulate onto the matching token.
|
|
$signalReg = [ordered]@{}
|
|
function Add-Signal {
|
|
param([string] $Token, [string] $Pattern, [string] $Domain, [string] $Source, [double] $Weight, [string] $Effect = 'raise')
|
|
if ($Effect -ne 'suppress') { $Effect = 'raise' }
|
|
# Key by token + domain + effect: "FindSet raises Performance" and
|
|
# "FindSet suppresses Privacy" are semantically distinct signals and must
|
|
# not collide. Same (token,domain,effect) still merges so articles accumulate.
|
|
$key = "$Token`n$Domain`n$Effect"
|
|
if (-not $signalReg.Contains($key)) {
|
|
$signalReg[$key] = [ordered]@{
|
|
token = $Token; pattern = $Pattern; domain = $Domain; source = $Source; weight = $Weight; effect = $Effect
|
|
articles = [System.Collections.Generic.List[string]]::new()
|
|
}
|
|
}
|
|
return $signalReg[$key]
|
|
}
|
|
|
|
# 1) Seed signals: guarantee recall >= legacy catalog. Attach every article
|
|
# whose canonical domain matches the seed signal's domain.
|
|
foreach ($s in $seed.signals) {
|
|
$seedEffect = if ($s.PSObject.Properties.Name -contains 'effect') { [string]$s.effect } else { 'raise' }
|
|
$sig = Add-Signal -Token $s.token -Pattern $s.pattern -Domain $s.domain -Source 'seed' -Weight 1.0 -Effect $seedEffect
|
|
# Suppressors do not route to articles — they only dampen a domain score.
|
|
if ($sig.effect -ne 'suppress' -and $articlesByDomain.ContainsKey($s.domain)) {
|
|
foreach ($p in $articlesByDomain[$s.domain]) { if (-not $sig.articles.Contains($p)) { $sig.articles.Add($p) | Out-Null } }
|
|
}
|
|
}
|
|
|
|
# 2) Article-declared signals (front-matter `signals:`): highest-precision,
|
|
# authored. Default pattern = \b<token>\b; default domain = article domain.
|
|
# An entry with `effect: suppress` DAMPENS its domain's score instead of
|
|
# raising it (e.g. "this-is-not-a-violation" articles), and does not route.
|
|
foreach ($a in $articles) {
|
|
foreach ($decl in $a.signals) {
|
|
$token = $null; $pattern = $null; $domain = $null; $effect = 'raise'
|
|
if ($decl -is [string]) { $token = $decl }
|
|
elseif ($decl -is [hashtable]) { $token = $decl['token']; $pattern = $decl['pattern']; $domain = $decl['domain']; if ($decl['effect']) { $effect = [string]$decl['effect'] } }
|
|
if (-not $token) { continue }
|
|
if (-not $pattern) { $pattern = '\b' + [regex]::Escape($token) + '\b' }
|
|
$domain = if ($domain) { ConvertTo-CanonicalDomain $domain } else { $a.domain }
|
|
$sig = Add-Signal -Token $token -Pattern $pattern -Domain $domain -Source 'frontmatter-signal' -Weight 1.0 -Effect $effect
|
|
# Promote a seed placeholder to authored if the article overrode it.
|
|
if ($sig.source -eq 'seed' -and $decl -isnot [string]) { $sig.source = 'frontmatter-signal'; $sig.pattern = $pattern }
|
|
# A suppressor's declaring article is a "not-a-violation" note, not a
|
|
# reading-list target, so it is not attached to the routed articles.
|
|
if ($sig.effect -ne 'suppress' -and -not $sig.articles.Contains($a.path)) { $sig.articles.Add($a.path) | Out-Null }
|
|
}
|
|
}
|
|
|
|
# 3) OPTIONAL soft keyword signals (off by default).
|
|
if ($IncludeKeywordSignals) {
|
|
$stop = @{ 'al' = $true; 'bc' = $true; 'all' = $true; 'w1' = $true; 'data' = $true; 'field' = $true; 'table' = $true; 'page' = $true; 'record' = $true; 'value' = $true }
|
|
foreach ($a in $articles) {
|
|
foreach ($kw in $a.keywords) {
|
|
$k = ($kw + '').Trim().ToLowerInvariant()
|
|
if (-not $k -or $stop.ContainsKey($k) -or $k.Length -lt 4) { continue }
|
|
$token = 'kw:' + $k
|
|
$pattern = '(?i)\b' + [regex]::Escape($k) + '\b'
|
|
$sig = Add-Signal -Token $token -Pattern $pattern -Domain $a.domain -Source 'keyword' -Weight 0.5
|
|
if (-not $sig.articles.Contains($a.path)) { $sig.articles.Add($a.path) | Out-Null }
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---- Assemble output -------------------------------------------------------
|
|
$signalsOut = @($signalReg.Values | ForEach-Object {
|
|
[ordered]@{
|
|
token = $_.token; pattern = $_.pattern; domain = $_.domain
|
|
source = $_.source; weight = $_.weight; effect = $_.effect; articles = @($_.articles)
|
|
}
|
|
})
|
|
|
|
$domainsOut = [ordered]@{}
|
|
foreach ($d in ($articlesByDomain.Keys | Sort-Object)) {
|
|
$domainsOut[$d] = [ordered]@{
|
|
articleCount = $articlesByDomain[$d].Count
|
|
signalCount = @($signalsOut | Where-Object { $_.domain -eq $d }).Count
|
|
}
|
|
}
|
|
|
|
$objKind = [ordered]@{}
|
|
foreach ($p in $seed.'object-kind-domain'.PSObject.Properties) { $objKind[$p.Name] = $p.Value }
|
|
$domNormOut = [ordered]@{}
|
|
foreach ($k in ($domainNorm.Keys | Sort-Object)) { $domNormOut[$k] = $domainNorm[$k] }
|
|
|
|
$index = [ordered]@{
|
|
'$schema-note' = 'BCQuality routing index. signal-token -> domain + backing articles. Consumed by the PR-review orchestrator manifest (BCQ_INDEX_V2). Regenerated at CI time by tools/Build-RoutingIndex.ps1; NOT the lean agent-replayed knowledge index. Schema: tools/routing-index.schema.json.'
|
|
version = 1
|
|
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
|
generator = 'Build-RoutingIndex.ps1'
|
|
enabledLayers = @($EnabledLayers)
|
|
articleCount = $articles.Count
|
|
signalCount = $signalsOut.Count
|
|
domainNormalization = $domNormOut
|
|
objectKindDomain = $objKind
|
|
signals = $signalsOut
|
|
domains = $domainsOut
|
|
}
|
|
|
|
$indexDir = Split-Path -Parent $IndexPath
|
|
if ($indexDir -and -not (Test-Path $indexDir)) { New-Item -ItemType Directory -Force -Path $indexDir | Out-Null }
|
|
if ($Pretty) {
|
|
$index | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $IndexPath -Encoding UTF8
|
|
} else {
|
|
$index | ConvertTo-Json -Depth 12 -Compress | Set-Content -LiteralPath $IndexPath -Encoding UTF8
|
|
}
|
|
|
|
Write-Host "BCQuality routing index: $($signalsOut.Count) signal(s), $($articles.Count) article(s). Index: $IndexPath"
|
|
return $signalsOut.Count
|