mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-07 01:46:53 +01:00
Add unit tests and knowledge files for BC domain context
- Introduced unit tests for the bc-domain-context implementation, covering various scenarios including filtering by application area, technology mismatches, layer precedence, and conditional applicability. - Added knowledge files related to finance, including topics such as Chart of Accounts, Codeunit 12, Dimension Management, and VAT on prepayment chains, among others. - Each knowledge file includes structured metadata and best practices to enhance the domain knowledge available for Business Central tasks.
This commit is contained in:
parent
0142e1e0de
commit
7fbb121c24
18 changed files with 1170 additions and 1 deletions
373
.github/scripts/bc_domain_context.py
vendored
Normal file
373
.github/scripts/bc_domain_context.py
vendored
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reference implementation of the bc-domain-context action skill.
|
||||
|
||||
Executes the skill's Source -> Relevance -> Worklist -> Action pipeline against
|
||||
a BCQuality repository root and returns a findings-report dict conforming to
|
||||
the DO output contract.
|
||||
|
||||
This module is the *spec by example* for consumers that reimplement the skill
|
||||
in other languages (for example, the Triage agent's Node.js client). Tests in
|
||||
.github/scripts/tests/test_bc_domain_context.py exercise this implementation
|
||||
against fixture knowledge trees.
|
||||
|
||||
Usage (from Python):
|
||||
from bc_domain_context import run_bc_domain_context
|
||||
report = run_bc_domain_context(repo_root, task_context)
|
||||
|
||||
Skill behaviour is defined in microsoft/skills/bc-domain-context.md; READ's
|
||||
frontmatter-matching semantics live in skills/read.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.stderr.write("ERROR: PyYAML is required. Install with: pip install pyyaml\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
LAYERS = ("custom", "microsoft", "community") # highest precedence first
|
||||
SKILL_ID = "bc-domain-context"
|
||||
SKILL_VERSION = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeFile:
|
||||
path: str # repo-relative, forward slashes
|
||||
layer: str # "microsoft" | "community" | "custom"
|
||||
domain: str # folder name
|
||||
slug: str # filename stem
|
||||
frontmatter: dict[str, Any]
|
||||
title: str # first H1 in body, or slug if absent
|
||||
description: str # prose after "## Description", trimmed
|
||||
|
||||
|
||||
# --- Frontmatter + body parsing ---------------------------------------------
|
||||
|
||||
def _parse_markdown(text: str) -> tuple[dict[str, Any] | None, str]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].rstrip() != "---":
|
||||
return None, text
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].rstrip() == "---":
|
||||
try:
|
||||
fm = yaml.safe_load("\n".join(lines[1:i])) or {}
|
||||
except yaml.YAMLError:
|
||||
return None, text
|
||||
if not isinstance(fm, dict):
|
||||
return None, text
|
||||
return fm, "\n".join(lines[i + 1:])
|
||||
return None, text
|
||||
|
||||
|
||||
def _extract_title(body: str, fallback: str) -> str:
|
||||
for line in body.splitlines():
|
||||
m = re.match(r"^#\s+(.+?)\s*$", line)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def _extract_description(body: str) -> str:
|
||||
lines = body.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^##\s+Description\s*$", line):
|
||||
buf: list[str] = []
|
||||
for j in range(i + 1, len(lines)):
|
||||
if re.match(r"^##\s+", lines[j]):
|
||||
break
|
||||
buf.append(lines[j])
|
||||
return "\n".join(buf).strip()
|
||||
return ""
|
||||
|
||||
|
||||
# --- bc-version expansion (matches validate_frontmatter.expand_bc_version) --
|
||||
|
||||
_RANGE = re.compile(r"^(\d+)\.\.(\d+)$")
|
||||
|
||||
|
||||
def _expand_bc_version(value: Any) -> list[int] | None:
|
||||
if not isinstance(value, list) or not value:
|
||||
return None
|
||||
if all(isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value):
|
||||
return sorted(set(value))
|
||||
if len(value) == 1 and isinstance(value[0], str):
|
||||
m = _RANGE.match(value[0].strip())
|
||||
if m:
|
||||
start, end = int(m.group(1)), int(m.group(2))
|
||||
if start <= end:
|
||||
return list(range(start, end + 1))
|
||||
return None
|
||||
|
||||
|
||||
# --- READ's frontmatter matching rules --------------------------------------
|
||||
|
||||
def _matches(
|
||||
kf: KnowledgeFile, task_context: dict[str, Any]
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Return (applicable, unknown_dimensions) per READ's semantics.
|
||||
|
||||
A file is applicable when every rule matches. A rule is "unknown" when the
|
||||
task context omits a dimension and the file does not declare a universal
|
||||
sentinel for it; unknown rules do not disqualify the file but force a
|
||||
medium-confidence ceiling on derived findings.
|
||||
"""
|
||||
unknown: list[str] = []
|
||||
fm = kf.frontmatter
|
||||
|
||||
# bc-version
|
||||
file_versions = _expand_bc_version(fm.get("bc-version"))
|
||||
if file_versions is None:
|
||||
return False, unknown
|
||||
task_version = task_context.get("bc-version")
|
||||
if task_version is None:
|
||||
unknown.append("bc-version")
|
||||
elif task_version not in file_versions:
|
||||
return False, unknown
|
||||
|
||||
# technologies (no sentinel)
|
||||
file_techs = set(fm.get("technologies") or [])
|
||||
task_techs = set(task_context.get("technologies") or [])
|
||||
if not task_techs:
|
||||
unknown.append("technologies")
|
||||
elif not (file_techs & task_techs):
|
||||
return False, unknown
|
||||
|
||||
# countries (sentinel: w1)
|
||||
file_countries = set(fm.get("countries") or [])
|
||||
task_countries = set(task_context.get("countries") or [])
|
||||
if "w1" in file_countries:
|
||||
pass
|
||||
elif not task_countries:
|
||||
unknown.append("countries")
|
||||
elif not (file_countries & task_countries):
|
||||
return False, unknown
|
||||
|
||||
# application-area (sentinel: all)
|
||||
file_areas = set(fm.get("application-area") or [])
|
||||
task_areas = set(task_context.get("application-area") or [])
|
||||
if "all" in file_areas:
|
||||
pass
|
||||
elif not task_areas or "all" in task_areas:
|
||||
# Empty or [all] in task means "any area"; file-level specific area
|
||||
# still matches against any-area when sourced from the area folder.
|
||||
pass
|
||||
elif not (file_areas & task_areas):
|
||||
return False, unknown
|
||||
|
||||
return True, unknown
|
||||
|
||||
|
||||
# --- Knowledge corpus load --------------------------------------------------
|
||||
|
||||
def _load_knowledge_file(root: Path, path: Path) -> KnowledgeFile | None:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
parts = rel.split("/")
|
||||
if len(parts) != 4 or parts[1] != "knowledge":
|
||||
return None
|
||||
layer, _, domain, filename = parts
|
||||
slug = Path(filename).stem
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
fm, body = _parse_markdown(text)
|
||||
if fm is None:
|
||||
return None
|
||||
return KnowledgeFile(
|
||||
path=rel,
|
||||
layer=layer,
|
||||
domain=domain,
|
||||
slug=slug,
|
||||
frontmatter=fm,
|
||||
title=_extract_title(body, slug),
|
||||
description=_extract_description(body),
|
||||
)
|
||||
|
||||
|
||||
def _load_corpus(
|
||||
root: Path, enabled_layers: Iterable[str], areas: Iterable[str] | None
|
||||
) -> list[KnowledgeFile]:
|
||||
"""Walk <layer>/knowledge/<area>/*.md for enabled layers.
|
||||
|
||||
If `areas` is None or contains "all", walk every area folder. Otherwise,
|
||||
walk only the named area folders.
|
||||
"""
|
||||
enabled = set(enabled_layers) & set(LAYERS)
|
||||
area_set = None
|
||||
if areas is not None:
|
||||
areas_list = list(areas)
|
||||
if areas_list and "all" not in areas_list:
|
||||
area_set = set(areas_list)
|
||||
corpus: list[KnowledgeFile] = []
|
||||
for layer in enabled:
|
||||
knowledge_root = root / layer / "knowledge"
|
||||
if not knowledge_root.is_dir():
|
||||
continue
|
||||
for domain_dir in knowledge_root.iterdir():
|
||||
if not domain_dir.is_dir():
|
||||
continue
|
||||
if area_set is not None and domain_dir.name not in area_set:
|
||||
continue
|
||||
for md_path in domain_dir.glob("*.md"):
|
||||
kf = _load_knowledge_file(root, md_path)
|
||||
if kf is not None:
|
||||
corpus.append(kf)
|
||||
return corpus
|
||||
|
||||
|
||||
# --- Layer precedence -------------------------------------------------------
|
||||
|
||||
def _resolve_precedence(
|
||||
files: list[KnowledgeFile],
|
||||
) -> tuple[list[KnowledgeFile], list[dict[str, Any]]]:
|
||||
"""Keep the highest-precedence file per (domain, slug). Return (kept, suppressed).
|
||||
|
||||
suppressed entries have shape { "reference": { "path": ... }, "reason": "layer-precedence" }.
|
||||
"""
|
||||
precedence = {layer: i for i, layer in enumerate(LAYERS)} # lower index = higher precedence
|
||||
groups: dict[tuple[str, str], list[KnowledgeFile]] = {}
|
||||
for f in files:
|
||||
groups.setdefault((f.domain, f.slug), []).append(f)
|
||||
kept: list[KnowledgeFile] = []
|
||||
suppressed: list[dict[str, Any]] = []
|
||||
for group in groups.values():
|
||||
group.sort(key=lambda f: precedence.get(f.layer, 99))
|
||||
kept.append(group[0])
|
||||
for loser in group[1:]:
|
||||
suppressed.append({
|
||||
"reference": {"path": loser.path},
|
||||
"reason": "layer-precedence",
|
||||
})
|
||||
return kept, suppressed
|
||||
|
||||
|
||||
# --- Worklist narrowing -----------------------------------------------------
|
||||
|
||||
_GOAL_PREFIX = re.compile(r"^bc-domain-context(?:\s+for\s+[a-z0-9,\- ]+)?\s*", re.IGNORECASE)
|
||||
_TOKEN = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def _extract_goal_tokens(goal: str) -> list[str]:
|
||||
"""Strip the 'bc-domain-context for <area>' prefix and return significant tokens."""
|
||||
if not goal:
|
||||
return []
|
||||
stripped = _GOAL_PREFIX.sub("", goal).strip()
|
||||
if not stripped:
|
||||
return []
|
||||
return [t for t in _TOKEN.findall(stripped.lower()) if len(t) >= 3]
|
||||
|
||||
|
||||
def _score(kf: KnowledgeFile, tokens: list[str]) -> int:
|
||||
if not tokens:
|
||||
return 0
|
||||
kw = {k.lower() for k in (kf.frontmatter.get("keywords") or [])}
|
||||
text = f"{kf.slug} {kf.title} {kf.description}".lower()
|
||||
score = 0
|
||||
for t in tokens:
|
||||
if t in kw:
|
||||
score += 3
|
||||
if t in text:
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
def _narrow(
|
||||
kept: list[KnowledgeFile], task_context: dict[str, Any], max_top: int = 15
|
||||
) -> list[KnowledgeFile]:
|
||||
tokens = _extract_goal_tokens(task_context.get("goal") or "")
|
||||
if not tokens:
|
||||
return kept
|
||||
scored = [(kf, _score(kf, tokens)) for kf in kept]
|
||||
scored.sort(key=lambda p: (-p[1], p[0].path))
|
||||
# Keep only files with positive score; fall back to full set if none score.
|
||||
positive = [kf for kf, s in scored if s > 0]
|
||||
if not positive:
|
||||
return kept
|
||||
return positive[:max_top]
|
||||
|
||||
|
||||
# --- Message construction ---------------------------------------------------
|
||||
|
||||
def _message(kf: KnowledgeFile, unknown: list[str]) -> str:
|
||||
first_sentences = re.split(r"(?<=[.!?])\s+", kf.description, maxsplit=3)
|
||||
lead = " ".join(first_sentences[:3]).strip() if first_sentences else ""
|
||||
msg = f"{kf.title}. {lead}" if lead else kf.title
|
||||
if unknown:
|
||||
msg += f" (conditional on: {', '.join(sorted(unknown))})"
|
||||
return msg
|
||||
|
||||
|
||||
# --- Main entrypoint --------------------------------------------------------
|
||||
|
||||
def run_bc_domain_context(
|
||||
root: Path | str, task_context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the skill against the repository at `root`.
|
||||
|
||||
Returns a findings-report dict conforming to the DO output contract.
|
||||
"""
|
||||
root = Path(root)
|
||||
enabled = task_context.get("enabled-layers") or list(LAYERS)
|
||||
areas = task_context.get("application-area")
|
||||
|
||||
corpus = _load_corpus(root, enabled, areas)
|
||||
if not corpus:
|
||||
return _report("no-knowledge", [], [])
|
||||
|
||||
applicable: list[tuple[KnowledgeFile, list[str]]] = []
|
||||
for kf in corpus:
|
||||
ok, unknown = _matches(kf, task_context)
|
||||
if ok:
|
||||
applicable.append((kf, unknown))
|
||||
if not applicable:
|
||||
return _report("not-applicable", [], [])
|
||||
|
||||
kept, suppressed = _resolve_precedence([kf for kf, _ in applicable])
|
||||
kept_set = {kf.path for kf in kept}
|
||||
unknown_by_path = {kf.path: u for kf, u in applicable if kf.path in kept_set}
|
||||
|
||||
worklist = _narrow(kept, task_context)
|
||||
|
||||
findings: list[dict[str, Any]] = []
|
||||
for kf in worklist:
|
||||
unknown = unknown_by_path.get(kf.path, [])
|
||||
findings.append({
|
||||
"id": kf.path,
|
||||
"severity": "info",
|
||||
"message": _message(kf, unknown),
|
||||
"references": [{"path": kf.path}],
|
||||
"confidence": "medium" if unknown else "high",
|
||||
})
|
||||
|
||||
return _report("completed", findings, suppressed)
|
||||
|
||||
|
||||
def _report(
|
||||
outcome: str, findings: list[dict[str, Any]], suppressed: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"skill": {"id": SKILL_ID, "version": SKILL_VERSION},
|
||||
"outcome": outcome,
|
||||
"summary": {
|
||||
"counts": {
|
||||
"blocker": 0,
|
||||
"major": 0,
|
||||
"minor": 0,
|
||||
"info": len(findings),
|
||||
},
|
||||
"coverage": {
|
||||
"worklist-size": len(findings),
|
||||
"items-evaluated": len(findings),
|
||||
},
|
||||
},
|
||||
"findings": findings,
|
||||
"suppressed": suppressed,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue