Build spec and host brief carried in from C:\Claude and WRPS/02-env; the plant model (equipment, tags, alarm bitmask, enums, unit conversions) is derived from WRPS/04-plc/register-map.csv, WRPS/05-scada/modbus/scada-points.csv and WRPS-CTL-003. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
6 KiB
Python
169 lines
6 KiB
Python
"""Alias resolution: what the operator said -> what the plant calls it.
|
|
|
|
"Pump 02" is equipment. Its data lives on tags. No historian point is called
|
|
"Pump 02", so without this module every equipment-level question fails.
|
|
|
|
Resolution is a database lookup against the GIN-indexed alias arrays, not a
|
|
model call. It is deterministic, it is cheap, and when it is wrong the fix goes
|
|
in db/seed/tags.csv - not in a prompt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
|
|
from config import settings
|
|
|
|
log = logging.getLogger("tools.equipment")
|
|
|
|
|
|
@dataclass
|
|
class Resolved:
|
|
canonical_id: str
|
|
display_name: str
|
|
kind: str # "equipment" or "tag"
|
|
matched_alias: str
|
|
confidence: float # 1.0 exact id, 0.9 exact alias, lower for fuzzy
|
|
description: str = ""
|
|
|
|
|
|
def _normalise(text: str) -> str:
|
|
"""Fold the ways an operator types the same thing.
|
|
|
|
'Pump 02', 'pump 2', 'PUMP-2' and 'pump2' all become 'pump 2'. Leading
|
|
zeros go, because 'P-002' and 'P2' are the same unit to everyone except a
|
|
string comparison.
|
|
"""
|
|
t = text.strip().lower()
|
|
t = re.sub(r"[-_/]+", " ", t)
|
|
t = re.sub(r"\s+", " ", t)
|
|
t = re.sub(r"\b0+(\d)", r"\1", t)
|
|
return t
|
|
|
|
|
|
def _connect() -> psycopg.Connection:
|
|
cfg = settings()
|
|
return psycopg.connect(
|
|
cfg.dsn(),
|
|
row_factory=dict_row,
|
|
application_name="ai-api", # so DBAs can see who is connecting
|
|
connect_timeout=5,
|
|
)
|
|
|
|
|
|
def resolve(term: str, *, conn: psycopg.Connection | None = None) -> list[Resolved]:
|
|
"""Resolve one operator term to equipment and/or tags, best first.
|
|
|
|
Returns every plausible match rather than picking one. An ambiguous term is
|
|
a clarifying question, not a coin toss - agent.py surfaces the alternatives.
|
|
"""
|
|
owned = conn is None
|
|
conn = conn or _connect()
|
|
try:
|
|
needle = _normalise(term)
|
|
out: list[Resolved] = []
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT equipment_id AS id, display_name, description,
|
|
aliases, 'equipment' AS kind
|
|
FROM equipment
|
|
WHERE lower(equipment_id) = %(raw)s
|
|
OR lower(display_name) = %(raw)s
|
|
OR EXISTS (SELECT 1 FROM unnest(aliases) a
|
|
WHERE lower(a) = %(raw)s)
|
|
UNION ALL
|
|
SELECT tag_id AS id, display_name, description, aliases, 'tag' AS kind
|
|
FROM tags
|
|
WHERE lower(tag_id) = %(raw)s
|
|
OR lower(display_name) = %(raw)s
|
|
OR EXISTS (SELECT 1 FROM unnest(aliases) a
|
|
WHERE lower(a) = %(raw)s)
|
|
""",
|
|
{"raw": term.strip().lower()},
|
|
)
|
|
for row in cur.fetchall():
|
|
out.append(
|
|
Resolved(
|
|
canonical_id=row["id"],
|
|
display_name=row["display_name"],
|
|
kind=row["kind"],
|
|
matched_alias=term.strip(),
|
|
confidence=1.0 if row["id"].lower() == term.strip().lower() else 0.9,
|
|
description=row["description"] or "",
|
|
)
|
|
)
|
|
|
|
if out:
|
|
return sorted(out, key=lambda r: -r.confidence)
|
|
|
|
# Nothing matched literally. Try the normalised forms, in Python, so
|
|
# the same folding applies to both sides.
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT equipment_id AS id, display_name, description, aliases,"
|
|
" 'equipment' AS kind FROM equipment"
|
|
" UNION ALL "
|
|
"SELECT tag_id AS id, display_name, description, aliases,"
|
|
" 'tag' AS kind FROM tags"
|
|
)
|
|
for row in cur.fetchall():
|
|
candidates = [row["id"], row["display_name"], *(row["aliases"] or [])]
|
|
for candidate in candidates:
|
|
if candidate and _normalise(candidate) == needle:
|
|
out.append(
|
|
Resolved(
|
|
canonical_id=row["id"],
|
|
display_name=row["display_name"],
|
|
kind=row["kind"],
|
|
matched_alias=candidate,
|
|
confidence=0.8,
|
|
description=row["description"] or "",
|
|
)
|
|
)
|
|
break
|
|
|
|
return sorted(out, key=lambda r: -r.confidence)
|
|
finally:
|
|
if owned:
|
|
conn.close()
|
|
|
|
|
|
def tags_for_equipment(equipment_id: str, *, conn: psycopg.Connection | None = None) -> list[dict]:
|
|
"""Every tag belonging to a piece of equipment, with its metadata.
|
|
|
|
The description field says whether the tag is historised at all. Field
|
|
inputs to the PLC have no history; an answer that trends PU-301 vibration
|
|
is fabricating data. Check before querying Cube for it.
|
|
"""
|
|
owned = conn is None
|
|
conn = conn or _connect()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT tag_id, display_name, signal_type, engineering_unit,"
|
|
" range_low, range_high, alarm_setpoint_hi, alarm_setpoint_lo,"
|
|
" trip_setpoint, description"
|
|
" FROM tags WHERE equipment_id = %s ORDER BY tag_id",
|
|
(equipment_id,),
|
|
)
|
|
return cur.fetchall()
|
|
finally:
|
|
if owned:
|
|
conn.close()
|
|
|
|
|
|
def is_historised(tag_row: dict) -> bool:
|
|
"""Whether a tag has history to query.
|
|
|
|
Encoded in the description because the CSV is the source of truth and a
|
|
boolean column would drift from it. HISTORISED and NOT HISTORISED are
|
|
written in capitals at the start of the description for exactly this.
|
|
"""
|
|
return not (tag_row.get("description") or "").upper().startswith("NOT HISTORISED")
|