Refuse a retired PS_* name instead of finding nothing
With the seed rekeyed, a question asking after PS_STN_WET_WELL_LEVEL now matches nothing - and the assistant answers "no records found". An operator reads that as "the plant recorded nothing", not as "you asked with a name this system retired". That is precisely the confusion the four namespaces exist to prevent, and an empty result is the wrong shape of answer for it. equipment.resolve() now rejects those names up front, raising ContractViolation, which main.py already turns into an error rather than an answer. The message says what the name was, why it is not a tag, and what to use instead. The four Modbus poll groups keep the prefix legitimately - they are groups, not names, and nothing resolves an operator term onto one - so PS_STATUS_BITS, PS_PUBLISHED, PS_SETPOINTS and PS_SIM_CONTROL pass. Scope, deliberately small: this covers equipment.resolve(), the path an operator's words take. Calling metrics directly with a retired id is not covered. For a demo that is the right trade; for production it is not. Verified by lifting the guard's own source out of the file and exercising it - upper case, lower case, surrounding whitespace, the _SP variant, all four poll groups, and the item, instrument and plain-English forms. The module itself could not be imported here: no psycopg on this machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3d19b186da
commit
f3c8020ac1
1 changed files with 32 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ import psycopg
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from contracts import ContractViolation
|
||||||
|
|
||||||
log = logging.getLogger("tools.equipment")
|
log = logging.getLogger("tools.equipment")
|
||||||
|
|
||||||
|
|
@ -56,12 +57,43 @@ def _connect() -> psycopg.Connection:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# PS_* IS NOT A NAMESPACE, AND ASKING FOR ONE IS AN ERROR - NOT AN EMPTY RESULT.
|
||||||
|
#
|
||||||
|
# The point list delivered in August carried names like PS_STN_WET_WELL_LEVEL
|
||||||
|
# where the CI Server ITEM name belongs (AID.WRPS.STN.LEVEL). CI Server never
|
||||||
|
# used them; they were a proposal derived from the PLC register map. A corrected
|
||||||
|
# delivery arrived 2026-09-01 and the seed carries no PS_ key any more.
|
||||||
|
#
|
||||||
|
# Without this guard the old names simply match nothing, and the assistant says
|
||||||
|
# "no records found" - which an operator reads as "the plant recorded nothing",
|
||||||
|
# not as "you asked with a retired name". That is the exact confusion the four
|
||||||
|
# namespaces exist to prevent, so it fails loudly instead.
|
||||||
|
#
|
||||||
|
# The four Modbus POLL GROUPS keep the prefix legitimately. They are groups, not
|
||||||
|
# point or tag names, and nothing resolves an operator term onto one.
|
||||||
|
_POLL_GROUPS = {"PS_STATUS_BITS", "PS_PUBLISHED", "PS_SETPOINTS", "PS_SIM_CONTROL"}
|
||||||
|
_RETIRED_TAG = re.compile(r"^PS_[A-Z][A-Z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def reject_retired_tag(term: str) -> None:
|
||||||
|
"""Raise if `term` is one of the retired PS_* names. See the note above."""
|
||||||
|
candidate = term.strip().upper()
|
||||||
|
if _RETIRED_TAG.match(candidate) and candidate not in _POLL_GROUPS:
|
||||||
|
raise ContractViolation(
|
||||||
|
f"{candidate} is a retired name and is not a tag in this system. "
|
||||||
|
"It came from a superseded SCADA point list; CI Server never used it. "
|
||||||
|
"Use the CI Server item name instead, for example AID.WRPS.STN.LEVEL. "
|
||||||
|
"See db/seed/scada-source/README.md."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve(term: str, *, conn: psycopg.Connection | None = None) -> list[Resolved]:
|
def resolve(term: str, *, conn: psycopg.Connection | None = None) -> list[Resolved]:
|
||||||
"""Resolve one operator term to equipment and/or tags, best first.
|
"""Resolve one operator term to equipment and/or tags, best first.
|
||||||
|
|
||||||
Returns every plausible match rather than picking one. An ambiguous term is
|
Returns every plausible match rather than picking one. An ambiguous term is
|
||||||
a clarifying question, not a coin toss - agent.py surfaces the alternatives.
|
a clarifying question, not a coin toss - agent.py surfaces the alternatives.
|
||||||
"""
|
"""
|
||||||
|
reject_retired_tag(term)
|
||||||
owned = conn is None
|
owned = conn is None
|
||||||
conn = conn or _connect()
|
conn = conn or _connect()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue