mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-30 03:41:58 +02:00
feat: GA-intent classification to cut real-project noise (Track D)
Classify every group address as functional/reserve/logic/scratch (intent.py) and reclassify intentional non-functional GAs instead of crying wolf: - reserve spare with no DPT -> INFO reserve_without_dpt (not a missing_dpt error) - reserve name reused across DPTs -> no duplicate_name / inconsistent_dpt - logic/virtual + scratch GAs -> excluded from missing-status warnings dpt_mismatch_co and all real functional findings are untouched. Driven by a real 685-GA Zennio project: false errors 29 -> 6, missing-status noise 79 -> 45, all 12 real dpt_mismatch_co catches preserved. Intent breakdown now exposed via list_group_addresses, report inventory and the analyze_all summary. New regression test covers the reserve/logic/scratch patterns and proves real DPT + status problems still surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9a8d87869a
commit
2ffb6d371f
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **GA-intent classification — noise reduction on real projects** (`intent.py`). Every group
|
||||
address is now classified as `functional` / `reserve` / `logic` / `scratch`. Intentional
|
||||
non-functional GAs no longer "cry wolf": reserve spares with no DPT become an INFO note
|
||||
(`reserve_without_dpt`) instead of a 🔴 error; reserve names repeated across different DPTs
|
||||
are not flagged `duplicate_name` / `inconsistent_dpt`; internal logic / virtual signals and
|
||||
scratch leftovers are excluded from missing-status warnings. The `dpt_mismatch_co` check and
|
||||
every real functional finding are untouched. Driven by a real 685-GA Zennio project where
|
||||
this cut false errors **29 → 6** and missing-status noise **79 → 45** while preserving all
|
||||
12 real `dpt_mismatch_co` catches. `list_group_addresses`, the report inventory and the
|
||||
`analyze_all` summary now expose the intent breakdown. New regression test covers the
|
||||
reserve / logic / scratch patterns (and proves real DPT + status problems still surface).
|
||||
- `docs/` — a self-contained GitHub Pages landing site (project overview, two-layer
|
||||
architecture, demo-house stats, an interactive 5-tab dashboard preview, the HA "brain",
|
||||
and a call for testers).
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Any, Optional
|
||||
|
||||
from .project import LoadedProject, GARecord, STATUS_KEYWORDS
|
||||
from .pairing import find_status, function_status_pairs
|
||||
from .intent import INTENT_FUNCTIONAL, INTENT_RESERVE, INTENT_SCRATCH
|
||||
|
||||
SEVERITY_ERROR = "error"
|
||||
SEVERITY_WARN = "warning"
|
||||
@@ -58,6 +59,12 @@ def validate_naming(project: LoadedProject,
|
||||
))
|
||||
continue
|
||||
|
||||
# Reserve / logic / scratch GAs are intentional placeholders — short
|
||||
# names, repeated "Резерв", non-conventional names are expected, so they
|
||||
# must not raise naming warnings (that is the noise we are removing).
|
||||
if ga.intent != INTENT_FUNCTIONAL:
|
||||
continue
|
||||
|
||||
seen_names[name.lower()].append(addr)
|
||||
|
||||
if len(name) < min_name_len:
|
||||
@@ -180,6 +187,8 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]:
|
||||
for addr, ga in project.gas.items():
|
||||
if addr in covered:
|
||||
continue
|
||||
if ga.intent != INTENT_FUNCTIONAL:
|
||||
continue # reserve / logic / scratch GAs need no status by design
|
||||
if ga.kind != "command":
|
||||
continue
|
||||
if ga.dpt_main is None:
|
||||
@@ -207,12 +216,22 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]:
|
||||
# 1. missing DPT
|
||||
for addr, ga in project.gas.items():
|
||||
if ga.dpt_main is None:
|
||||
findings.append(_finding(
|
||||
SEVERITY_ERROR, "missing_dpt", addr,
|
||||
f"'{ga.name}' has no DPT assigned. Home Assistant requires a DPT "
|
||||
"to decode this group address.",
|
||||
name=ga.name,
|
||||
))
|
||||
# A reserve / scratch GA with no DPT is intentional (a spare), so it
|
||||
# is an INFO note, not a 🔴 error that blocks the project.
|
||||
if ga.intent in (INTENT_RESERVE, INTENT_SCRATCH):
|
||||
findings.append(_finding(
|
||||
SEVERITY_INFO, "reserve_without_dpt", addr,
|
||||
f"'{ga.name}' is a {ga.intent} placeholder with no DPT — "
|
||||
"intentional, assign a DPT only when you start using it.",
|
||||
name=ga.name, intent=ga.intent,
|
||||
))
|
||||
else:
|
||||
findings.append(_finding(
|
||||
SEVERITY_ERROR, "missing_dpt", addr,
|
||||
f"'{ga.name}' has no DPT assigned. Home Assistant requires a DPT "
|
||||
"to decode this group address.",
|
||||
name=ga.name,
|
||||
))
|
||||
|
||||
# 2. CO <-> GA dpt mismatch
|
||||
cos = project.raw.get("communication_objects", {})
|
||||
@@ -242,6 +261,10 @@ def detect_dpt_issues(project: LoadedProject) -> list[dict[str, Any]]:
|
||||
if ga.name.strip():
|
||||
by_name[ga.name.strip().lower()].append(ga)
|
||||
for low, recs in by_name.items():
|
||||
# Reserve / logic / scratch GAs intentionally share a generic name
|
||||
# ("Резерв") across different DPTs — not an inconsistency.
|
||||
if recs[0].intent != INTENT_FUNCTIONAL:
|
||||
continue
|
||||
dpts = {r.dpt for r in recs if r.dpt}
|
||||
if len(dpts) > 1:
|
||||
findings.append(_finding(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Classify a group address by INTENT: functional / reserve / logic / scratch.
|
||||
|
||||
Real ETS projects are full of intentional NON-functional group addresses:
|
||||
* **reserve** — spare placeholders ("Резерв", "Reserve", "Spare") left for
|
||||
future use, often with no DPT on purpose;
|
||||
* **logic** — internal/virtual signals used only inside the bus program
|
||||
(intermediate logic results, summed signals, time markers, request triggers)
|
||||
that have no physical state to read back;
|
||||
* **scratch** — test/leftover GAs ("Новый групповой адрес", a bare "1").
|
||||
|
||||
Treating these like functional device GAs is what makes the tool "cry wolf" on a
|
||||
real project: a spare with no DPT becomes a 🔴 error, a logic signal becomes a
|
||||
"missing status" 🟡 warning. This classifier lets the checks reclassify that
|
||||
noise (downgrade / skip) instead of drowning the real findings.
|
||||
|
||||
Name-based and deliberately conservative: when unsure we return ``functional`` so
|
||||
a real problem is never hidden. Multilingual (RU / EN / DE).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
INTENT_FUNCTIONAL = "functional"
|
||||
INTENT_RESERVE = "reserve"
|
||||
INTENT_LOGIC = "logic"
|
||||
INTENT_SCRATCH = "scratch"
|
||||
|
||||
# Spare / reserved placeholders. "резерв" lower-cases both "Резерв" and "РЕЗЕРВ".
|
||||
_RESERVE_TOKENS = (
|
||||
"резерв", "reserve", "reserved", "spare", "запас", "не использ", "unused",
|
||||
"не задейств",
|
||||
)
|
||||
|
||||
# Internal logic / virtual signals — no physical status by design.
|
||||
_LOGIC_TOKENS = (
|
||||
"промежуточн", "суммарн", "логик", "logic", "метка времени", "выход ф-ци",
|
||||
"ф-ция управлен", "ф-ции управлен", "ф-ция упр", "дубль", "вспомогат",
|
||||
"intermediate", "internal signal", "virtual", "запрос", "request",
|
||||
"годовой период", "сигнал включения", "сигнал отключения",
|
||||
)
|
||||
|
||||
# Known scratch / default ETS names (exact, lower-cased).
|
||||
_SCRATCH_EXACT = (
|
||||
"новый групповой адрес", "new group address", "new group object",
|
||||
"neue gruppenadresse",
|
||||
)
|
||||
|
||||
|
||||
def classify_intent(name: str) -> str:
|
||||
"""Return one of functional / reserve / logic / scratch for a GA name."""
|
||||
raw = (name or "").strip()
|
||||
if not raw:
|
||||
# Empty name is a separate, real problem (``empty_name``); leave it
|
||||
# functional so that check still fires.
|
||||
return INTENT_FUNCTIONAL
|
||||
|
||||
low = raw.lower()
|
||||
|
||||
if low in _SCRATCH_EXACT:
|
||||
return INTENT_SCRATCH
|
||||
# Bare numeric placeholders like "1", "5", "12" — classic leftovers.
|
||||
if re.fullmatch(r"\d{1,3}", raw):
|
||||
return INTENT_SCRATCH
|
||||
|
||||
if any(t in low for t in _RESERVE_TOKENS):
|
||||
return INTENT_RESERVE
|
||||
if any(t in low for t in _LOGIC_TOKENS):
|
||||
return INTENT_LOGIC
|
||||
|
||||
return INTENT_FUNCTIONAL
|
||||
@@ -15,6 +15,7 @@ from xknxproject import XKNXProj
|
||||
from xknxproject.models import KNXProject
|
||||
|
||||
from .dpt_map import classify_dpt, dpt_key
|
||||
from .intent import classify_intent, INTENT_FUNCTIONAL
|
||||
|
||||
|
||||
# Multilingual keyword sets (EN / DE / RU) used by the heuristic fallbacks.
|
||||
@@ -71,6 +72,10 @@ class GARecord:
|
||||
ha_platform: str
|
||||
value_type: Optional[str]
|
||||
label: str
|
||||
# Purpose of the GA: functional / reserve / logic / scratch. Non-functional
|
||||
# GAs are intentional noise (spares, internal logic, leftovers) and the
|
||||
# checks reclassify them instead of raising false errors/warnings.
|
||||
intent: str = INTENT_FUNCTIONAL
|
||||
# 3-level decomposition
|
||||
main: Optional[int] = None
|
||||
middle: Optional[int] = None
|
||||
@@ -203,6 +208,7 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject:
|
||||
ha_platform=ha_platform,
|
||||
value_type=info["value_type"],
|
||||
label=info["label"],
|
||||
intent=classify_intent(ga.get("name", "")),
|
||||
main=m, middle=mid, sub=s,
|
||||
main_name=range_names.get(str(m), "") if m is not None else "",
|
||||
middle_name=range_names.get(f"{m}/{mid}", "") if m is not None and mid is not None else "",
|
||||
|
||||
@@ -43,8 +43,10 @@ def build_report(project: LoadedProject,
|
||||
|
||||
cat_counts = Counter(ga.category for ga in gas.values())
|
||||
kind_counts = Counter(ga.kind for ga in gas.values())
|
||||
intent_counts = Counter(ga.intent for ga in gas.values())
|
||||
no_dpt = sum(1 for ga in gas.values() if ga.dpt_main is None)
|
||||
secure = sum(1 for ga in gas.values() if ga.data_secure)
|
||||
non_functional = sum(v for k, v in intent_counts.items() if k != "functional")
|
||||
|
||||
all_findings = naming + status + dpts
|
||||
sev_counts = Counter(f["severity"] for f in all_findings)
|
||||
@@ -71,6 +73,14 @@ def build_report(project: LoadedProject,
|
||||
", ".join(f"{k}={v}" for k, v in sorted(cat_counts.items())) + "\n")
|
||||
md.append("**By kind:** " +
|
||||
", ".join(f"{k}={v}" for k, v in sorted(kind_counts.items())) + "\n")
|
||||
if non_functional:
|
||||
md.append(
|
||||
"**By purpose:** " +
|
||||
", ".join(f"{k}={v}" for k, v in sorted(intent_counts.items())) +
|
||||
f" — {non_functional} non-functional GA(s) (reserve / logic / scratch) "
|
||||
"are excluded from error and missing-status checks to keep the report "
|
||||
"focused on real device addresses.\n"
|
||||
)
|
||||
|
||||
md.append("\n## 2. Findings\n")
|
||||
md.append(
|
||||
@@ -112,6 +122,7 @@ def build_report(project: LoadedProject,
|
||||
"warnings": sev_counts.get("warning", 0),
|
||||
"info": sev_counts.get("info", 0),
|
||||
"missing_status": len(status),
|
||||
"intent": dict(intent_counts),
|
||||
"ha_entities": {k: v for k, v in c.items() if k != "review"},
|
||||
"ha_review": c["review"],
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ def list_group_addresses(category: Optional[str] = None,
|
||||
continue
|
||||
out.append({
|
||||
"address": ga.address, "name": ga.name, "dpt": ga.dpt,
|
||||
"category": ga.category, "kind": ga.kind,
|
||||
"category": ga.category, "kind": ga.kind, "intent": ga.intent,
|
||||
"ha_platform": ga.ha_platform, "secure": ga.data_secure,
|
||||
"description": ga.description,
|
||||
})
|
||||
|
||||
@@ -250,3 +250,66 @@ _covers7 = _pkg7.get("cover", [])
|
||||
assert len(_covers7) == 1 and _covers7[0].get("move_short_address") == "2/0/1", _covers7
|
||||
assert any(r["reason"] == "manual_datetime" for r in _ha7["review"]), "datetime not routed to review"
|
||||
print("OK: full light entity (no dup switch); 1.001/1.017 cover; datetime -> review")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Regression (Track D): GA-intent classification removes real-project NOISE.
|
||||
# Patterns taken verbatim from the real Minsk (Zennio) project, where reserve
|
||||
# spares + internal logic GAs produced ~29 false errors and dozens of false
|
||||
# missing-status warnings. Non-functional GAs must be reclassified, while a real
|
||||
# functional problem must STILL be flagged (no over-suppression).
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n=== REGRESSION: GA-intent noise reduction (reserve / logic / scratch) ===")
|
||||
from nickol_knx_mcp.intent import (
|
||||
classify_intent, INTENT_RESERVE, INTENT_LOGIC, INTENT_SCRATCH, INTENT_FUNCTIONAL,
|
||||
)
|
||||
|
||||
# Direct classifier checks on real names.
|
||||
assert classify_intent("Резерв") == INTENT_RESERVE
|
||||
assert classify_intent("РЕЗЕРВ") == INTENT_RESERVE
|
||||
assert classify_intent("Промежуточный результат логики индикации") == INTENT_LOGIC
|
||||
assert classify_intent("Метеостанция - Запрос температуры") == INTENT_LOGIC
|
||||
assert classify_intent("суммарный сигнал включения групп света") == INTENT_LOGIC
|
||||
assert classify_intent("1") == INTENT_SCRATCH
|
||||
assert classify_intent("Новый групповой адрес") == INTENT_SCRATCH
|
||||
assert classify_intent("Kitchen ceiling light switch") == INTENT_FUNCTIONAL
|
||||
|
||||
_rawD = {"group_addresses": {
|
||||
# reserve spares: no DPT (intentional) + same name, different DPTs
|
||||
"0/1/8": ga("0/1/8", "Резерв", None, None),
|
||||
"1/2/49": ga("1/2/49", "Резерв", 5, 1),
|
||||
"1/2/50": ga("1/2/50", "Резерв", 1, 3),
|
||||
# internal logic command with no status (should NOT be flagged)
|
||||
"1/1/7": ga("1/1/7", "Промежуточный результат логики индикации", 1, 1),
|
||||
"8/0/18": ga("8/0/18", "Метеостанция - Запрос температуры", 1, 17),
|
||||
# scratch leftover
|
||||
"9/2/0": ga("9/2/0", "1", 1, 1),
|
||||
# a REAL functional problem that must survive: missing DPT + missing status
|
||||
"3/1/21": ga("3/1/21", "07. Спальня - выход Д СО - порог 2", None, None),
|
||||
"1/0/0": ga("1/0/0", "Kitchen ceiling light switch", 1, 1),
|
||||
}}
|
||||
_pD = build_loaded_from_raw(_rawD, "mem")
|
||||
assert _pD.gas["0/1/8"].intent == INTENT_RESERVE
|
||||
assert _pD.gas["1/1/7"].intent == INTENT_LOGIC
|
||||
|
||||
_dptD = detect_dpt_issues(_pD)
|
||||
_codes = {(f["code"], f["address"]) for f in _dptD}
|
||||
# reserve with no DPT -> INFO reserve_without_dpt, NOT a missing_dpt error
|
||||
assert ("reserve_without_dpt", "0/1/8") in _codes, _codes
|
||||
assert ("missing_dpt", "0/1/8") not in _codes, "reserve wrongly raised a DPT error"
|
||||
# functional missing DPT still a real error
|
||||
assert ("missing_dpt", "3/1/21") in _codes, "real missing DPT was suppressed!"
|
||||
# "Резерв" reused with different DPTs is NOT an inconsistency
|
||||
assert not any(f["code"] == "inconsistent_dpt" for f in _dptD), "reserve flagged inconsistent_dpt"
|
||||
|
||||
_naflD = validate_naming(_pD)
|
||||
# reserve dupes + scratch short name must not raise naming warnings
|
||||
assert not any(f["code"] == "duplicate_name" for f in _naflD), "reserve flagged duplicate_name"
|
||||
assert not any(f["code"] == "name_too_short" for f in _naflD), "scratch '1' flagged name_too_short"
|
||||
|
||||
_msD = {f["address"] for f in detect_missing_status(_pD)}
|
||||
assert "1/1/7" not in _msD, "logic GA wrongly flagged missing_status"
|
||||
assert "8/0/18" not in _msD, "weather request wrongly flagged missing_status"
|
||||
assert "9/2/0" not in _msD, "scratch GA wrongly flagged missing_status"
|
||||
# the real functional switch with no status IS still flagged
|
||||
assert "1/0/0" in _msD, "real functional command lost its missing-status warning!"
|
||||
print("OK: reserve/logic/scratch de-noised; real DPT + status problems preserved")
|
||||
|
||||
Reference in New Issue
Block a user