check_policy: example profile seeded from the loaded project's own main groups (issue #13)

- example_policy_yaml(project): mains written with inferred domain, range name and
  category mix; no-majority mains commented-out with their mix; defaults only as a
  labelled comment; reserve.expect_range follows the project; empty/2-level -> {}.
- taxonomy_seed() is the single source for _infer_taxonomy and the example.
- server.check_policy(write_example_to) passes the loaded project, reports seeded_from.
- tests/test_policy.py: cases 4-6 (no leaked mains, round-trip, mixed main, quotes, no project); added to CI.
- README/README.ru/CHANGELOG.
This commit is contained in:
Nikolay Miroshnichenko
2026-09-09 20:03:32 +02:00
parent 05a0658a3a
commit a32b1970f3
7 changed files with 154 additions and 32 deletions
+3
View File
@@ -46,6 +46,9 @@ jobs:
- name: RM/Rückmeldung shutter feedback classifies as status, not light (issue #12)
run: python tests/test_rm_status.py
- name: Policy profile — inferred taxonomy, declared profile, project-seeded example (issue #13)
run: python tests/test_policy.py
- name: Console script is installed
run: |
python -c "import importlib.metadata as m; print('entry points:', [e.name for e in m.entry_points(group='console_scripts') if e.name == 'nickol-knx-mcp'])"
+11
View File
@@ -6,6 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **`check_policy(write_example_to=…)` writes an example profile seeded from the loaded project's own
main groups** instead of a static template (issue #13, avataru). The template listed mains the project
did not have (sensor/energy/diagnostics/reserve), contradicting the real layout and sending the model off
to "correct" a fine taxonomy. Now: each existing main is written with its inferred domain, range name and
category mix; mains with no clear majority are written commented-out with their mix so the integrator
decides; the default taxonomy stays only as a labelled comment; `reserve.expect_range` follows the
project. `_infer_taxonomy` and the example now share one implementation (`taxonomy_seed`). Regression
cases in `tests/test_policy.py` (now in CI).
### Added
- **`check_topology()` — topology & individual-address sanity, grounded in the KNX standard** (tool
+1 -1
View File
@@ -289,7 +289,7 @@ keyring handling, and the recommended workflow).
| `check_matter()` | Matter-readiness lint (which functions round-trip to a Matter cluster) |
| `check_energy()` | metering/energy DPT check + PV/battery/EVSE scaffold |
| `analyze_all(name_regex?)` | run every check at once |
| `check_policy(profile_path?, write_example_to?)` | validate against a **Project Policy Profile** (your main-group taxonomy, naming, pairing) — or, with no profile, against the taxonomy **inferred from the project itself**; flags GAs that deviate from *your* convention, not a universal standard |
| `check_policy(profile_path?, write_example_to?)` | validate against a **Project Policy Profile** (your main-group taxonomy, naming, pairing) — or, with no profile, against the taxonomy **inferred from the project itself**; flags GAs that deviate from *your* convention, not a universal standard. `write_example_to` writes an example profile **seeded from the loaded project's own main groups** (mains the project doesn't have are never listed) |
**Repair & design**
| Tool | Purpose |
+1 -1
View File
@@ -253,7 +253,7 @@ claude mcp add nickol-knx -e NICKOL_KNX_WORKSPACE="$HOME/knx-workspace" -- /abs/
| `check_matter()` | Matter-готовность функций |
| `check_energy()` | метеринг/энергодомен |
| `analyze_all(name_regex?)` | все проверки разом |
| `check_policy(profile_path?, write_example_to?)` | проверка по **Project Policy Profile** (ваша таксономия main-групп, именование, парность) — или, без профиля, по таксономии, **выведенной из самого проекта**; флагует GA, отклоняющиеся от *вашей* конвенции, а не от универсального стандарта |
| `check_policy(profile_path?, write_example_to?)` | проверка по **Project Policy Profile** (ваша таксономия main-групп, именование, парность) — или, без профиля, по таксономии, **выведенной из самого проекта**; флагует GA, отклоняющиеся от *вашей* конвенции, а не от универсального стандарта. `write_example_to` пишет пример профиля, **собранный из главных групп загруженного проекта** (несуществующие группы не пишутся) |
| `suggest_repairs()` | предложить фиксы для находок |
| `suggest_names()` | гигиена именования |
| `decompose_device(order_number, channels?)` | устройство → декомпозиция GA: **точная вендорская модель** из локального каталога (`NICKOL_KNX_CATALOG`) или generic-рецепт |
+88 -24
View File
@@ -94,20 +94,10 @@ def _infer_taxonomy(project: LoadedProject, min_group: int = 3,
min_share: float = 0.6) -> dict[int, list[str]]:
"""Infer each main group's domain from the project itself (dominant category
with a clear majority). Mains with no clear majority are left out — we do not
guess a taxonomy the project doesn't actually follow."""
per_main: dict[int, Counter] = defaultdict(Counter)
for ga in project.gas.values():
if ga.intent != INTENT_FUNCTIONAL or ga.main is None:
continue
if ga.category and ga.category != "unknown":
per_main[ga.main][ga.category] += 1
tax: dict[int, list[str]] = {}
for m, c in per_main.items():
total = sum(c.values())
dom, n = c.most_common(1)[0]
if total >= min_group and n / total >= min_share:
tax[m] = [dom]
return tax
guess a taxonomy the project doesn't actually follow. (Thin view over
``taxonomy_seed`` so the example profile and the checker cannot drift.)"""
return {m: info["domain"] for m, info in
taxonomy_seed(project, min_group, min_share).items() if info["domain"]}
def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, Any]:
@@ -222,14 +212,55 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An
}
def example_policy_yaml() -> str:
"""A commented example profile an integrator can copy and adapt."""
return (
def taxonomy_seed(project: LoadedProject, min_group: int = 3,
min_share: float = 0.6) -> dict[int, dict[str, Any]]:
"""Per main group of THIS project: the inferred domain (or None when there is
no clear majority), the main-range name, and the category mix behind it.
Used to seed an example profile from the project instead of from a template."""
per_main: dict[int, Counter] = defaultdict(Counter)
names: dict[int, str] = {}
unknown: Counter = Counter()
for ga in project.gas.values():
if ga.intent != INTENT_FUNCTIONAL or ga.main is None:
continue
if ga.main_name and ga.main not in names:
names[ga.main] = ga.main_name
if ga.category and ga.category != "unknown":
per_main[ga.main][ga.category] += 1
else:
unknown[ga.main] += 1
per_main.setdefault(ga.main, Counter())
seed: dict[int, dict[str, Any]] = {}
for m in sorted(per_main):
c = per_main[m]
total = sum(c.values())
dom, n = c.most_common(1)[0] if total else (None, 0)
resolved = bool(total >= min_group and dom and n / total >= min_share)
seed[m] = {
"domain": [dom] if resolved else None,
"name": names.get(m, ""),
"total": total,
"unknown": unknown.get(m, 0),
"mix": [(k, round(v / total * 100)) for k, v in c.most_common(3)] if total else [],
}
return seed
def example_policy_yaml(project: Optional[LoadedProject] = None) -> str:
"""A commented example profile an integrator can copy and adapt.
With a loaded project the ``main_groups`` block is **seeded from that
project's own inferred taxonomy** (issue #13: a static template listed main
groups the project does not have, and the model went off to "correct" a
perfectly fine layout). Mains with no clear majority are listed commented-out
with their category mix so the integrator decides. Without a project, the
default methodology taxonomy is written, clearly labelled as such.
"""
head = (
"# nickol-knx Project Policy Profile — your project's rules, not a universal standard.\n"
"# Pass its path to check_policy(profile_path=...). Any key you omit falls back to the default.\n"
"name: \"My project policy\"\n\n"
"# Which functional domain each main group is meant to hold:\n"
"main_groups:\n"
)
default_block = (
" 0: [central, scene]\n"
" 1: [lighting]\n"
" 2: [shutter]\n"
@@ -237,14 +268,47 @@ def example_policy_yaml() -> str:
" 4: [sensor]\n"
" 5: [energy]\n"
" 6: [diagnostics]\n"
" 7: [reserve]\n\n"
"naming:\n"
" 7: [reserve]\n"
)
tail = (
"\nnaming:\n"
" # names must match this regex (omit to skip); e.g. Zone_Function_Role:\n"
" regex: null\n"
" status_suffix: Status\n\n"
"pairing:\n"
" require_status_for: [lighting, shutter, hvac]\n"
" exempt: [scene, sensor, central, diagnostics, energy]\n\n"
"reserve:\n"
" expect_range: true\n"
)
if project is None:
return (head + "name: \"My project policy\"\n\n"
"# Which functional domain each main group is meant to hold.\n"
"# (No project loaded: this is the DEFAULT methodology taxonomy, not yours —\n"
"# load_project first to get an example seeded from your own main groups.)\n"
"main_groups:\n" + default_block + tail +
"reserve:\n expect_range: true\n")
seed = taxonomy_seed(project)
pname = str((project.info or {}).get("name") or project.path).replace("\\", "/").replace('"', "'")
lines = [head, f"# Seeded from project \"{pname}\": {len(seed)} main group(s) present.\n",
f"name: \"{pname} policy\"\n\n",
"# Which functional domain each main group holds — inferred from THIS project.\n",
"# Uncomment / edit any line you disagree with; mains not listed do not exist here.\n",
"main_groups:\n" if seed else
"main_groups: {} # no three-level main groups found (two-level / free style?) — declare yours here\n"]
for m, info in seed.items():
label = f'"{info["name"]}"' if info["name"] else "(unnamed)"
mix = ", ".join(f"{k} {v} %" for k, v in info["mix"]) or "no classified GAs"
unk = f", {info['unknown']} unknown" if info["unknown"] else ""
if info["domain"]:
lines.append(f" {m}: [{info['domain'][0]}] # {label}: {info['total']} GAs — {mix}{unk}\n")
else:
lines.append(f" # {m}: [] # {label}: mixed, no clear majority ({mix}{unk}) — decide yourself\n")
lines.append("\n# For reference only — the default methodology taxonomy (NOT your project's):\n")
lines.extend("#" + l + "\n" for l in default_block.rstrip("\n").split("\n"))
has_reserve = any(i["domain"] == ["reserve"] for i in seed.values()) or any(
t in (i["name"] or "").lower() for i in seed.values() for t in ("reserve", "spare", "резерв"))
lines.append(tail)
lines.append("reserve:\n" + (
" expect_range: true\n" if has_reserve else
" expect_range: false # no reserve main group found in this project; set true if you keep one\n"))
return "".join(lines)
+11 -4
View File
@@ -399,11 +399,18 @@ def check_policy(profile_path: Optional[str] = None,
agreed rules (main-group taxonomy, naming regex, command/status exemptions),
not one universal "standard". Flags GAs whose domain doesn't match the main
group your policy assigns, and names that don't match your pattern. Pass
`profile_path` to a YAML profile (omit to use the CLAUDE.md default, which is
a starting profile to override). Set `write_example_to` to drop a commented
example profile into the workspace. Report-only."""
`profile_path` to a YAML profile (omit to validate against the taxonomy inferred
from the project itself). Set `write_example_to` to drop a commented example
profile into the workspace — **seeded from the loaded project's own main groups**
(mains that do not exist in the project are not written). Report-only."""
if write_example_to:
return {"example_written": _safe_write(write_example_to, _example_policy_yaml())}
proj = _STATE["project"]
seeded = (f"project '{(proj.info or {}).get('name') or proj.path}' (main groups inferred "
f"from the project itself)" if proj is not None else
"defaults (no project loaded — call load_project first to get an example "
"seeded from your own main groups)")
return {"example_written": _safe_write(write_example_to, _example_policy_yaml(proj)),
"seeded_from": seeded}
return _check_policy(_project(), _load_policy(profile_path))
+39 -2
View File
@@ -12,7 +12,8 @@ import os
import tempfile
from nickol_knx_mcp.project import build_loaded_from_raw
from nickol_knx_mcp.policy import check_policy, load_policy
from nickol_knx_mcp.policy import check_policy, load_policy, example_policy_yaml
import yaml
def _ga(addr, name, dmain, dsub):
@@ -77,8 +78,44 @@ def main():
assert "1/0/4" not in bad # shutter GA now conforms
os.unlink(path)
# 4. issue #13 (avataru): the example profile must be seeded from THIS project's
# main groups — never list mains the project does not have (4..7 here).
ex = example_policy_yaml(p)
doc = yaml.safe_load(ex)
assert doc["main_groups"] == {1: ["lighting"], 2: ["shutter"]}, doc["main_groups"]
assert doc["reserve"]["expect_range"] is False, "no reserve main -> must not expect one"
live = [l for l in ex.splitlines() if not l.lstrip().startswith("#")]
assert not any(l.strip().startswith(("4:", "5:", "6:", "7:")) for l in live), \
"static default mains leaked into a project-seeded example"
assert "NOT your project's" in ex, "defaults must be present only as a labelled comment"
# round-trip: the seeded example is a valid profile and flags the same deviant
open(path, "w").write(ex)
res4 = check_policy(p, load_policy(path))
assert ("policy_domain_mismatch", "1/0/4") in {(f["code"], f["address"]) for f in res4["findings"]}
os.unlink(path)
# 5. a main with NO clear majority is written commented-out with its mix, not guessed
from nickol_knx_mcp.project import build_loaded_from_raw as _b
raw = {"info": {"project_id": "P-2", "name": 'Quote "Test"', "group_address_style": "ThreeLevel",
"schema_version": "21"},
"group_addresses": {
"0/0/1": _ga("0/0/1", "Central light", 1, 1), "0/0/2": _ga("0/0/2", "Central blind", 1, 8),
"0/0/3": _ga("0/0/3", "Central temp", 9, 1), "0/0/4": _ga("0/0/4", "Central light2", 1, 1),
}, "communication_objects": {}, "devices": {}, "functions": {}, "topology": {},
"group_ranges": {}}
p2 = _b(raw, "mixed.knxproj")
ex2 = example_policy_yaml(p2)
doc2 = yaml.safe_load(ex2) # quotes in the name must not break YAML
assert not doc2["main_groups"], f"mixed main must not be asserted: {doc2['main_groups']}"
assert "# 0:" in ex2 and "mixed" in ex2, ex2
# 6. without a project the default taxonomy is still written, labelled as default
ex0 = example_policy_yaml(None)
assert yaml.safe_load(ex0)["main_groups"][7] == ["reserve"] and "DEFAULT" in ex0
print("test_policy: OK — inferred taxonomy flags the deviant GA vs the project's own "
"majority; a declared profile is authoritative and changing it changes the findings.")
"majority; a declared profile is authoritative and changing it changes the findings; "
"the example profile is seeded from the project's own main groups (issue #13).")
if __name__ == "__main__":