mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
fix(P5/policy): dogfood round 3 — location stopwords, greedy-term pruning, reserve check
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ccfedbdeca
commit
6cabea08af
@@ -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", "всех ", "весь ", "мастер",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user