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:
Nikolay1
2026-07-15 11:36:50 +02:00
co-authored by Claude Opus 4.8
parent ccfedbdeca
commit 6cabea08af
3 changed files with 30 additions and 7 deletions
+1 -1
View File
@@ -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", "всех ", "весь ", "мастер",
)
+14 -3
View File
@@ -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")
+15 -3
View File
@@ -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