diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db8153e..9ecae7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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'])" diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e6fb2..f26a33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 616b999..7cbe1d9 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.ru.md b/README.ru.md index a2d171a..81b31db 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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-рецепт | diff --git a/nickol_knx_mcp/policy.py b/nickol_knx_mcp/policy.py index b1dde92..dfd8909 100644 --- a/nickol_knx_mcp/policy.py +++ b/nickol_knx_mcp/policy.py @@ -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) diff --git a/nickol_knx_mcp/server.py b/nickol_knx_mcp/server.py index d2a0fc6..ecfce51 100644 --- a/nickol_knx_mcp/server.py +++ b/nickol_knx_mcp/server.py @@ -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)) diff --git a/tests/test_policy.py b/tests/test_policy.py index 5c3a623..858b222 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -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__":