mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
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:
@@ -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'])"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user