From 6cabea08afdca405a6fbc7f0802f3fd7f17ab71f Mon Sep 17 00:00:00 2001 From: Nikolay1 Date: Wed, 15 Jul 2026 11:36:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(P5/policy):=20dogfood=20round=203=20?= =?UTF-8?q?=E2=80=94=20location=20stopwords,=20greedy-term=20pruning,=20re?= =?UTF-8?q?serve=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - location phrases no longer vote a domain: "Boiler room light" is a light in the boiler room, not an HVAC function (location stoplist stripped first). - dropped the bare "температур" hvac term: a temperature GA takes its domain from context (уставк/кондиц/тёпл words or its main group), so "Гостиная температура" in a Sensors main is a sensor, not an hvac actuator outlier. - "online/offline/heartbeat/watchdog/связь" are diagnostics terms (checked first), so actuator-monitoring GAs named after the load they watch stay diag. - check_policy: central macros ("Весь свет выкл") are never taxonomy outliers; policy_no_reserve now also fires under an INFERRED taxonomy when no reserve main exists at all; "весь "/"мастер" added to central-macro tokens. Messy-house dogfood now reports exactly the planted misplacements (3/3) and the reserve gap; demo 0 outliers. 13/13 tests green. Co-Authored-By: Claude Opus 4.8 --- nickol_knx_mcp/analyze.py | 2 +- nickol_knx_mcp/policy.py | 17 ++++++++++++++--- nickol_knx_mcp/project.py | 18 +++++++++++++++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/nickol_knx_mcp/analyze.py b/nickol_knx_mcp/analyze.py index b44288e..d1795f2 100644 --- a/nickol_knx_mcp/analyze.py +++ b/nickol_knx_mcp/analyze.py @@ -173,7 +173,7 @@ _CENTRAL_MACRO_TOKENS = ( "общее", "групповое", "все ", "всё", "central", "all groups", "all lights", # generic group/broadcast commands (multi-word so they don't false-match "wall") "all blinds", "all shutters", "all covers", "all windows", "all sockets", - "all off", "all on", "master off", "всех ", + "all off", "all on", "master off", "всех ", "весь ", "мастер", ) diff --git a/nickol_knx_mcp/policy.py b/nickol_knx_mcp/policy.py index 85e8843..7d4e1f9 100644 --- a/nickol_knx_mcp/policy.py +++ b/nickol_knx_mcp/policy.py @@ -25,6 +25,7 @@ from typing import Any, Optional from .project import LoadedProject from .intent import INTENT_FUNCTIONAL +from .analyze import _is_central_macro # Categories whose placement a main-group taxonomy actually constrains. Sensors, # diagnostics, scene links and metering are cross-cutting — they legitimately @@ -146,7 +147,9 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An # outliers is noise. A misplaced actuator (an HVAC load in the lighting # main) is the real signal. allowed = mg.get(ga.main) - if allowed and ga.category in _ACTUATOR_DOMAINS: + if allowed and ga.category in _ACTUATOR_DOMAINS and not _is_central_macro(ga.name): + # central macros ("Весь свет выкл", "All blinds down") legitimately + # live in the central/scene main — their domain is not misplacement if ga.category in allowed: domain_ok += 1 else: @@ -175,13 +178,21 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An # 3. reserve range expected? reserve_mains = [m for m, doms in mg.items() if "reserve" in doms] - if (policy.get("reserve") or {}).get("expect_range") and reserve_mains: - if not any(m in seen_mains for m in reserve_mains): + if (policy.get("reserve") or {}).get("expect_range"): + if reserve_mains and not any(m in seen_mains for m in reserve_mains): findings.append({ "severity": "info", "code": "policy_no_reserve", "address": "-", "message": f"Policy expects a reserve range (main {reserve_mains}) but none is " "populated — leave spare address space per this project's convention.", }) + elif not reserve_mains and source != "profile": + # inferred taxonomy: no main group is reserve-like at all — the + # project has no spare address space anywhere + findings.append({ + "severity": "info", "code": "policy_no_reserve", "address": "-", + "message": "No reserve main group exists in the project — every main is " + "populated. Leave spare address space for future extensions.", + }) errors = sum(1 for f in findings if f["severity"] == "error") warns = sum(1 for f in findings if f["severity"] == "warning") diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index 31b5d6b..e0f5aeb 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -38,7 +38,9 @@ COMMAND_KEYWORDS = [ # like "ac" don't fire inside "terrace" and "led" doesn't fire inside "ledge". _DOMAIN_TERMS: dict[str, list[str]] = { "diagnostics": ["alarm", "fault", "error", "diag", "leak", "smoke", - "тревог", "ошибк", "диагност", "утечк", "дым", "авари", "неисправ"], + "online", "offline", "heartbeat", "watchdog", + "тревог", "ошибк", "диагност", "утечк", "дым", "авари", "неисправ", + "связь"], "energy": ["energy", "power", "consum", "meter", "kwh", "watt", "энерг", "мощност", "потребл", "счётчик", "счетчик", "тариф"], "scene": ["scene", "preset", "mood", "сцен", "пресет"], @@ -56,9 +58,12 @@ _DOMAIN_TERMS: dict[str, list[str]] = { "hvac": ["hvac", "climate", "thermostat", "heat", "cool", "ac", "a/c", "aircon", "air con", "air-con", "conditioner", "ventilation", "radiator", "boiler", "underfloor", "fancoil", "fan coil", "fan-coil", "fan", "valve", "setpoint", - "температур", "климат", "отопл", "тёпл", "тепл", "конвектор", "вентил", + "климат", "отопл", "тёпл", "тепл", "конвектор", "вентил", "клапан", "кондиц", "котёл", "радиатор", "фанкойл", "уставк", "а/с", "сплит", "вытяжк"], + # NOTE: no bare "температур"/"temperature" term — a temperature GA takes its + # domain from context (уставк/кондиц/тёпл name words, or its main group): + # "Гостиная температура" in a Sensors main is a sensor, not an HVAC actuator. "lighting": ["light", "lamp", "dimm", "led", "spot", "sconce", "chandelier", "rgb", "rgbw", "colour", "color", "xyy", "свет", "лампа", "подсветк", "освещ", "люстра", "диммер", "бра", "торшер", @@ -89,12 +94,19 @@ def _compile_domain(terms: list[str]) -> "re.Pattern": _DOMAIN_RE = {dom: _compile_domain(terms) for dom, terms in _DOMAIN_TERMS.items()} +# Location phrases that CONTAIN a domain word but describe WHERE, not WHAT: +# "Boiler room light" is a light in the boiler room, not an HVAC function. +_LOCATION_STOP_RE = re.compile(r"\b(boiler\s*room|котельн\w*|laundry\s*room)\b") + + def _domain_from_text(text: str) -> Optional[str]: """Return the functional domain a piece of text (a GA or range name) implies, - or None. Word-boundary matching over _DOMAIN_TERMS, first domain by priority.""" + or None. Word-boundary matching over _DOMAIN_TERMS, first domain by priority; + known location phrases are stripped first so they don't vote.""" low = (text or "").lower() if not low: return None + low = _LOCATION_STOP_RE.sub(" ", low) for dom, rx in _DOMAIN_RE.items(): if rx.search(low): return dom