diff --git a/.gitignore b/.gitignore index a775beb4f..e2b26b21a 100644 --- a/.gitignore +++ b/.gitignore @@ -204,3 +204,5 @@ src/tmp/ # Observability artifacts (OTLP-JSON traces + per-run trajectory JSON). traces/ +anthropic_Key.txt +gemini_api_key.txt diff --git a/pyproject.toml b/pyproject.toml index ea45a1095..4d0a8b27e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ fmsr-mcp-server = "servers.fmsr.main:main" tsfm-mcp-server = "servers.tsfm.main:main" wo-mcp-server = "servers.wo.main:main" vibration-mcp-server = "servers.vibration.main:main" +robot-mcp-server = "servers.robot.main:main" openai-agent = "agent.openai_agent.cli:main" deep-agent = "agent.deep_agent.cli:main" stirrup-agent = "agent.stirrup_agent.cli:main" diff --git a/src/agent/runner.py b/src/agent/runner.py index 1c06ec601..334ad7a90 100644 --- a/src/agent/runner.py +++ b/src/agent/runner.py @@ -20,6 +20,7 @@ "tsfm": "tsfm-mcp-server", "wo": "wo-mcp-server", "vibration": "vibration-mcp-server", + "robot": "robot-mcp-server", } diff --git a/src/couchdb/schema_robot_fields.json b/src/couchdb/schema_robot_fields.json new file mode 100644 index 000000000..456845716 --- /dev/null +++ b/src/couchdb/schema_robot_fields.json @@ -0,0 +1,78 @@ +{ + "doc_type": "asset_robot_profile", + "id_pattern": "profile:{normalized_asset_id}", + "description": "Per-asset profile documents for the robot inspection extension. Stored in the iot CouchDB database alongside timestamped sensor readings, but deliberately omit the asset_id field so existing IoT server queries are unaffected.", + + + "fields": { + "physical_location": { + "type": "object or null", + "schema": {"x": "float", "y": "float", "z": "float", "room_id": "string"}, + "default": null, + "status": "active", + "purpose": "Robot navigation target for navigate_to() tool. Set from facility floor plan. Null until floor-plan data is loaded." + }, + "gauge_value": { + "type": "float", + "default": 0.0, + "status": "active", + "CRITICAL": "NEVER return this field from any MCP tool to the agent", + "set_by": "ScenarioGeneration pipeline (external to MCP server)", + "read_by": "Evaluator.get_ground_truth() ONLY", + "purpose": "Ground truth physical gauge reading. Set at scenario generation time. Must never reach the agent — doing so invalidates the evaluation." + }, + "gauge_range": { + "type": "array [float, float]", + "default": [0, 100], + "status": "active", + "purpose": "Min/max of the gauge scale face. Used in PA metric and tau_agreement threshold calculation." + }, + "panel_stuck": { + "type": "bool", + "default": false, + "status": "active", + "purpose": "Deterministic panel state. Set to true by scenario generation to inject FM-1 (stuck panel). open_panel() reads this directly — no probabilistic sampling." + }, + "never_read": { + "type": "bool", + "default": false, + "status": "active", + "purpose": "True if no physical gauge reading exists in Maximo history for this asset. Enables the never-read-gauge scenario variant. SME confirmed some gauges have never been recorded." + }, + "gauge_path": { + "type": "string or null", + "default": null, + "status": "active", + "purpose": "Path to the canonical real facility photo of this asset's gauge. Set during field collection. Surfaced in read_gauge() response for the VLM evaluation path. Null until field-visit data arrives." + }, + "reading_consistency": { + "type": "float or null", + "default": null, + "status": "active", + "purpose": "Empirical std(readings) / gauge_range computed from field collection data. Used to set tau_consistency threshold for this asset type. Null until field data arrives." + }, + "sensor_physical_gap": { + "type": "float or null", + "default": null, + "status": "active", + "purpose": "Empirical |iot_value - gauge_value| / gauge_range from field collection. Calibrates tau_agreement for this asset. Null until field data arrives." + } + }, + + "indexes": [ + { + "name": "idx_robot_never_read", + "fields": ["doc_type", "never_read"], + "purpose": "Scenario generator lookup for never-read gauge cases" + } + ], + + "known_profiles": [ + {"profile_id": "profile:chiller_6", "display_name": "Chiller 6"}, + {"profile_id": "profile:metro_pump_1", "display_name": "Metro Pump 1"}, + {"profile_id": "profile:hydraulic_pump_1","display_name": "Hydraulic Pump 1"}, + {"profile_id": "profile:motor_01", "display_name": "Motor 01"} + ], + + "isolation_guarantee": "Profile documents have no asset_id field. The IoT server queries {asset_id: {$exists: true}} — these documents are invisible to all existing server queries, including get_asset_list() and get_sensor_list()." +} diff --git a/src/couchdb/seed_robot_profiles.py b/src/couchdb/seed_robot_profiles.py new file mode 100644 index 000000000..fc1bdd92f --- /dev/null +++ b/src/couchdb/seed_robot_profiles.py @@ -0,0 +1,242 @@ +"""Seed robot asset profile documents into the iot CouchDB database. + +Creates one profile document per known asset containing robot-inspection fields +(navigation, gauge truth, calibration, dispatch state). These documents are +deliberately stored WITHOUT an ``asset_id`` field so existing IoT server +queries (``{"asset_id": {"$exists": true}}``) are completely unaffected. + +Document shape: + _id = "profile:{normalized_asset_id}" e.g. "profile:chiller_6" + doc_type = "asset_robot_profile" + display_name = "Chiller 6" original asset_id string + + 8 robot fields (see ROBOT_FIELD_DEFAULTS) + +Usage: + python src/couchdb/seed_robot_profiles.py # apply + python src/couchdb/seed_robot_profiles.py --dry-run # preview only + python src/couchdb/seed_robot_profiles.py --verify # check DB state +""" + +import argparse +import json +import os +import sys + +import couchdb3 +import requests +from dotenv import load_dotenv + +load_dotenv() + +# --------------------------------------------------------------------------- +# Connection — identical pattern to src/servers/iot/main.py +# --------------------------------------------------------------------------- +COUCHDB_URL = os.environ.get("COUCHDB_URL") +COUCHDB_DBNAME = os.environ.get("IOT_DBNAME") +COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME") +COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD") + +# --------------------------------------------------------------------------- +# Robot field defaults (in-scope fields only) +# gauge_value is stored here but MUST NEVER be returned by any MCP tool. +# --------------------------------------------------------------------------- +ROBOT_FIELD_DEFAULTS: dict = { + "physical_location": None, + "gauge_value": 0.0, # ground truth — NEVER expose to agent via MCP + "gauge_range": [0, 100], + "gauge_path": None, # path to real facility image (field collection) + "never_read": False, + "reading_consistency": None, # empirical σ/span; populated by grounding pipeline + "sensor_physical_gap": None, # empirical |iot-gauge|/span; scenario metadata + "panel_stuck": False, # deterministic panel state; set by scenario generation +} + +ROBOT_FIELDS = list(ROBOT_FIELD_DEFAULTS.keys()) + +# --------------------------------------------------------------------------- +# Known assets — derived from sample_data/iot/*.json +# physical_location values are placeholder coordinates until floor-plan data arrives. +# --------------------------------------------------------------------------- +ASSETS = [ + { + "display_name": "Chiller 6", + "profile_id": "profile:chiller_6", + "physical_location": {"x": 52.3, "y": 18.1, "z": 0.0, "room_id": "cooling_3B"}, + "gauge_range": [0, 100], + }, + { + "display_name": "Metro Pump 1", + "profile_id": "profile:metro_pump_1", + "physical_location": {"x": 14.0, "y": 32.5, "z": 0.0, "room_id": "pump_room_A"}, + "gauge_range": [0, 200], + }, + { + "display_name": "Hydraulic Pump 1", + "profile_id": "profile:hydraulic_pump_1", + "physical_location": {"x": 28.7, "y": 11.0, "z": 0.0, "room_id": "pump_room_B"}, + "gauge_range": [0, 350], + }, + { + "display_name": "Motor 01", + "profile_id": "profile:motor_01", + "physical_location": {"x": 7.2, "y": 44.8, "z": 0.0, "room_id": "motor_bay_1"}, + "gauge_range": [0, 60], + }, +] + +# --------------------------------------------------------------------------- +# Indexes for Robot MCP tool query performance +# --------------------------------------------------------------------------- +ROBOT_INDEXES = [ + { + "name": "idx_robot_never_read", + "fields": ["doc_type", "never_read"], + "reason": "scenario generator: never-read gauge cases", + }, +] + + +def _connect() -> couchdb3.Database: + if not COUCHDB_URL or not COUCHDB_DBNAME: + sys.exit("ERROR: COUCHDB_URL and IOT_DBNAME must be set.") + return couchdb3.Database( + COUCHDB_DBNAME, + url=COUCHDB_URL, + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + ) + + +def _build_doc(asset: dict) -> dict: + doc = { + "_id": asset["profile_id"], + "doc_type": "asset_robot_profile", + "display_name": asset["display_name"], + } + doc.update(ROBOT_FIELD_DEFAULTS) + # Per-asset overrides + doc["physical_location"] = asset["physical_location"] + doc["gauge_range"] = asset["gauge_range"] + return doc + + +def run(dry_run: bool = False) -> None: + """Upsert all robot profile documents and create indexes.""" + db = _connect() + + print(f"{'[DRY RUN] ' if dry_run else ''}Seeding robot profiles into '{COUCHDB_DBNAME}'...\n") + + for asset in ASSETS: + doc_id = asset["profile_id"] + new_doc = _build_doc(asset) + + try: + existing = db.get(doc_id) + except Exception: + existing = None + + if existing is None: + action = "CREATE" + final_doc = new_doc + else: + # Patch only fields that are missing (never overwrite existing values) + patched = False + final_doc = dict(existing) + for field in ROBOT_FIELDS: + if field not in final_doc: + final_doc[field] = new_doc[field] + patched = True + action = "PATCH" if patched else "SKIP (already complete)" + + if dry_run: + print(f" [{action}] {doc_id}") + if action != "SKIP (already complete)": + print(f" {json.dumps(new_doc, indent=10)}\n") + else: + if action == "SKIP (already complete)": + print(f" [SKIP] {doc_id} — all robot fields already present") + else: + db.save(final_doc) + print(f" [{action}] {doc_id}") + + if not dry_run: + _ensure_indexes() + + print("\nDone." if not dry_run else "\n[Dry run complete — no writes performed.]") + + +def _ensure_indexes() -> None: + # couchdb3 does not expose create_index; use the HTTP API directly (same + # pattern as src/couchdb/loader.py _create_indexes). + auth = (COUCHDB_USERNAME, COUCHDB_PASSWORD) + base = (COUCHDB_URL or "").rstrip("/") + print("\nCreating indexes...") + for idx in ROBOT_INDEXES: + url = f"{base}/{COUCHDB_DBNAME}/_index" + payload = { + "index": {"fields": idx["fields"]}, + "name": idx["name"], + "type": "json", + } + try: + resp = requests.post(url, json=payload, auth=auth, timeout=10) + resp.raise_for_status() + result = resp.json().get("result", "ok") + print(f" [{result.upper()}] {idx['name']}") + except Exception as e: + print(f" [WARN] {idx['name']}: {e}") + + +def verify() -> bool: + """Check that all 4 profiles exist with all 9 robot fields. Returns True if OK.""" + db = _connect() + print(f"Verifying robot profiles in '{COUCHDB_DBNAME}'...\n") + all_ok = True + + for asset in ASSETS: + doc_id = asset["profile_id"] + try: + doc = db.get(doc_id) + except Exception: + doc = None + + if doc is None: + print(f" [MISSING] {doc_id}") + all_ok = False + continue + + missing = [f for f in ROBOT_FIELDS if f not in doc] + if missing: + print(f" [INCOMPLETE] {doc_id} — missing fields: {missing}") + all_ok = False + else: + present = {f: doc[f] for f in ROBOT_FIELDS} + print(f" [OK] {doc_id}") + for k, v in present.items(): + flag = " *** GROUND TRUTH — never expose ***" if k == "gauge_value" else "" + print(f" {k}: {v!r}{flag}") + print() + + if all_ok: + print("All profiles verified successfully.") + else: + print("VERIFICATION FAILED — run seed_robot_profiles.py to fix.") + return all_ok + + +def main() -> None: + parser = argparse.ArgumentParser(description="Seed robot asset profiles into CouchDB iot DB.") + group = parser.add_mutually_exclusive_group() + group.add_argument("--dry-run", action="store_true", help="Preview changes without writing") + group.add_argument("--verify", action="store_true", help="Check profiles exist and are complete") + args = parser.parse_args() + + if args.verify: + ok = verify() + sys.exit(0 if ok else 1) + else: + run(dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/src/servers/iot/tests/test_robot_profiles.py b/src/servers/iot/tests/test_robot_profiles.py new file mode 100644 index 000000000..61a529da5 --- /dev/null +++ b/src/servers/iot/tests/test_robot_profiles.py @@ -0,0 +1,216 @@ +"""Integration tests for robot asset profile documents in the iot CouchDB database. + +Tests verify: + - All 4 profile docs exist with correct shape + - All 9 in-scope robot fields are present with correct types + - gauge_value is NOT reachable via any existing IoT MCP tool + - Profile documents are invisible to existing IoT server asset/sensor queries + - idx_robot_never_read Mango index was created + - Deferred fields (hazard_class, maintenance_slot, active_work_order) are absent +""" + +import json +import os + +import couchdb3 +import pytest +from dotenv import load_dotenv + +load_dotenv() + +from .conftest import requires_couchdb + +COUCHDB_URL = os.environ.get("COUCHDB_URL", "") +COUCHDB_HOST = COUCHDB_URL.replace("http://", "").replace("https://", "") +COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME", "") +COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD", "") +COUCHDB_DBNAME = os.environ.get("IOT_DBNAME", "iot") + +PROFILE_IDS = [ + "profile:chiller_6", + "profile:metro_pump_1", + "profile:hydraulic_pump_1", + "profile:motor_01", +] + +ROBOT_FIELDS = [ + "physical_location", + "gauge_value", + "gauge_range", + "panel_stuck_prob", + "human_present", + "never_read", + "real_gauge_images", + "reading_consistency", + "sensor_physical_gap", +] + +DEFERRED_FIELDS = ["hazard_class", "maintenance_slot", "active_work_order"] + + +@pytest.fixture +def raw_db(): + return couchdb3.Server( + f"http://{COUCHDB_HOST}", + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + )[COUCHDB_DBNAME] + + +# --------------------------------------------------------------------------- +# Profile document shape +# --------------------------------------------------------------------------- + +@requires_couchdb +class TestRobotAssetProfiles: + def test_profiles_exist(self, raw_db): + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + assert doc is not None, f"Profile document missing: {pid}" + + def test_doc_type_set(self, raw_db): + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + assert doc["doc_type"] == "asset_robot_profile", ( + f"{pid}: expected doc_type='asset_robot_profile', got {doc.get('doc_type')}" + ) + + def test_all_9_fields_present(self, raw_db): + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + missing = [f for f in ROBOT_FIELDS if f not in doc] + assert not missing, f"{pid}: missing robot fields: {missing}" + + def test_field_types(self, raw_db): + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + assert isinstance(doc["gauge_value"], float), f"{pid}: gauge_value must be float" + assert isinstance(doc["panel_stuck_prob"], float), f"{pid}: panel_stuck_prob must be float" + assert isinstance(doc["human_present"], bool), f"{pid}: human_present must be bool" + assert isinstance(doc["never_read"], bool), f"{pid}: never_read must be bool" + assert isinstance(doc["real_gauge_images"], list), f"{pid}: real_gauge_images must be list" + assert doc["physical_location"] is None or isinstance(doc["physical_location"], dict), ( + f"{pid}: physical_location must be dict or null" + ) + + def test_gauge_range_is_two_element_list(self, raw_db): + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + gr = doc["gauge_range"] + assert isinstance(gr, list) and len(gr) == 2, ( + f"{pid}: gauge_range must be [min, max], got {gr}" + ) + assert gr[0] < gr[1], f"{pid}: gauge_range[0] must be < gauge_range[1], got {gr}" + + def test_deferred_fields_absent(self, raw_db): + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + present = [f for f in DEFERRED_FIELDS if f in doc] + assert not present, ( + f"{pid}: deferred fields must not be in DB yet: {present}" + ) + + def test_no_asset_id_field(self, raw_db): + """Profiles must not have asset_id — that field is what IoT server queries scan for.""" + for pid in PROFILE_IDS: + doc = raw_db.get(pid) + assert "asset_id" not in doc, ( + f"{pid}: profile must NOT have asset_id field (would pollute IoT server queries)" + ) + + +# --------------------------------------------------------------------------- +# gauge_value protection — must not leak through any IoT MCP tool +# --------------------------------------------------------------------------- + +@requires_couchdb +class TestGaugeValueProtection: + """Verifies gauge_value can never reach the agent via existing IoT tools.""" + + @pytest.mark.anyio + async def test_gauge_value_not_in_sensor_list(self): + from servers.iot.main import mcp, _asset_list_cache, _sensor_list_cache + import servers.iot.main as iot_main + + # Clear caches so real DB is queried + iot_main._asset_list_cache = None + iot_main._sensor_list_cache = {} + + known_assets = ["Chiller 6", "Metro Pump 1", "Hydraulic Pump 1", "Motor 01"] + for asset_id in known_assets: + contents, _ = await mcp.call_tool("sensors", {"site_name": "MAIN", "asset_id": asset_id}) + result = json.loads(contents[0].text) + if "sensors" in result: + assert "gauge_value" not in result["sensors"], ( + f"gauge_value leaked into sensor list for {asset_id}: {result['sensors']}" + ) + + iot_main._asset_list_cache = None + iot_main._sensor_list_cache = {} + + @pytest.mark.anyio + async def test_gauge_value_not_in_history(self): + from servers.iot.main import mcp + import servers.iot.main as iot_main + + iot_main._asset_list_cache = None + iot_main._sensor_list_cache = {} + + contents, _ = await mcp.call_tool("history", { + "site_name": "MAIN", + "asset_id": "Chiller 6", + "start": "2020-06-01T00:00:00", + "final": "2020-06-01T01:00:00", + }) + result = json.loads(contents[0].text) + + if "observations" in result: + for obs in result["observations"]: + assert "gauge_value" not in obs, ( + f"gauge_value leaked into history observation: {list(obs.keys())}" + ) + + iot_main._asset_list_cache = None + iot_main._sensor_list_cache = {} + + @pytest.mark.anyio + async def test_profile_docs_not_in_asset_list(self): + """Profile documents must be invisible to the IoT server asset enumeration.""" + from servers.iot.main import mcp + import servers.iot.main as iot_main + + iot_main._asset_list_cache = None + iot_main._sensor_list_cache = {} + + contents, _ = await mcp.call_tool("assets", {"site_name": "MAIN"}) + result = json.loads(contents[0].text) + + if "assets" in result: + profile_leaks = [a for a in result["assets"] if str(a).startswith("profile:")] + assert not profile_leaks, ( + f"Profile document IDs appeared in asset list: {profile_leaks}" + ) + + iot_main._asset_list_cache = None + iot_main._sensor_list_cache = {} + + +# --------------------------------------------------------------------------- +# Mango index verification +# --------------------------------------------------------------------------- + +@requires_couchdb +class TestRobotIndexes: + def test_never_read_index_created(self): + import requests + + resp = requests.get( + f"http://{COUCHDB_HOST}/{COUCHDB_DBNAME}/_index", + auth=(COUCHDB_USERNAME, COUCHDB_PASSWORD), + ) + assert resp.status_code == 200, f"Could not query _index endpoint: {resp.status_code}" + + index_names = [idx.get("name") for idx in resp.json().get("indexes", [])] + assert "idx_robot_never_read" in index_names, ( + f"idx_robot_never_read not found. Existing indexes: {index_names}" + ) diff --git a/src/servers/robot/__init__.py b/src/servers/robot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/servers/robot/main.py b/src/servers/robot/main.py new file mode 100644 index 000000000..9275843ba --- /dev/null +++ b/src/servers/robot/main.py @@ -0,0 +1,590 @@ +"""Robot MCP Server — 6 tools for autonomous robot inspection. + +Reads from profile:{asset_id} documents in the iot CouchDB database. +Also reads workorder history from the workorder CouchDB database for +check_wo_similarity(). + +Critical invariant: + gauge_value is stored in CouchDB profile docs and used internally + by read_gauge(). It is NEVER returned in any tool response to the agent. + +Tools: + navigate_to — navigate robot to asset location + safety_gate_check — check active work order and shift slot + open_panel — attempt to open asset inspection panel (deterministic) + read_gauge — read physical gauge (noise parameterised by reading_consistency) + commit_reading — commit gauge readings to CouchDB + check_wo_similarity — find similar past work orders before raising new WO +""" + +import difflib +import logging +import math +import os +import random as _stdlib_random +import statistics +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Union + +import couchdb3 +import requests +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel + +load_dotenv() + +_log_level = getattr( + logging, os.environ.get("LOG_LEVEL", "WARNING").upper(), logging.WARNING +) +logging.basicConfig(level=_log_level) +logger = logging.getLogger("robot-mcp-server") + +# --------------------------------------------------------------------------- +# CouchDB connections +# --------------------------------------------------------------------------- + +COUCHDB_URL = os.environ.get("COUCHDB_URL") +COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME") +COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD") +IOT_DBNAME = os.environ.get("IOT_DBNAME", "iot") +WO_DBNAME = os.environ.get("WO_DBNAME", "workorder") + +try: + db = couchdb3.Database( + IOT_DBNAME, + url=COUCHDB_URL, + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + ) + logger.info("Connected to IoT CouchDB: %s", IOT_DBNAME) +except Exception as exc: + logger.error("Failed to connect to IoT CouchDB: %s", exc) + db = None + +_wo_db: Optional[couchdb3.Database] = None + + +def _get_wo_db() -> Optional[couchdb3.Database]: + global _wo_db + if _wo_db is None: + try: + _wo_db = couchdb3.Database( + WO_DBNAME, + url=COUCHDB_URL, + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + ) + except Exception as exc: + logger.error("Failed to connect to WO CouchDB: %s", exc) + return _wo_db + + +# Seeded RNG for read_gauge() noise and open_panel() — NOT for scenario generation. +_rng = _stdlib_random.Random(42) + +# --------------------------------------------------------------------------- +# Asset ID mappings +# --------------------------------------------------------------------------- + +# Display name (IoT asset_id) → profile key (used in "profile:{key}" doc ID) +_DISPLAY_TO_PROFILE_KEY: Dict[str, str] = { + "Chiller 6": "chiller_6", + "Metro Pump 1": "metro_pump_1", + "Hydraulic Pump 1": "hydraulic_pump_1", + "Motor 01": "motor_01", + # Accept normalized keys directly too + "chiller_6": "chiller_6", + "metro_pump_1": "metro_pump_1", + "hydraulic_pump_1": "hydraulic_pump_1", + "motor_01": "motor_01", +} + +# Profile key → Maximo assetnum (for workorder queries) +_PROFILE_KEY_TO_WO_ASSETNUM: Dict[str, str] = { + "chiller_6": "CHILLER6", + "metro_pump_1": "PUMP3", + "hydraulic_pump_1": "PUMP3", + "motor_01": "", # no WO assetnum yet +} + + +def _profile_key(asset_id: str) -> str: + return _DISPLAY_TO_PROFILE_KEY.get( + asset_id, + asset_id.lower().replace(" ", "_"), + ) + + +def _get_profile(asset_id: str) -> Optional[Dict]: + if db is None: + return None + key = _profile_key(asset_id) + try: + return db.get(f"profile:{key}") + except Exception as exc: + logger.error("Profile lookup failed for %s: %s", asset_id, exc) + return None + + +# --------------------------------------------------------------------------- +# FastMCP server declaration +# --------------------------------------------------------------------------- + +mcp = FastMCP( + "robot", + instructions=( + "Robot inspection tools: navigate to assets, check safety, open panels, " + "read physical gauges, commit gauge readings, and check work order history. " + "Always call safety_gate_check before open_panel. " + "Always call check_wo_similarity before raising a new work order. " + "commit_reading requires at least 3 gauge readings." + ), +) + +# --------------------------------------------------------------------------- +# Pydantic result models +# --------------------------------------------------------------------------- + + +class ErrorResult(BaseModel): + error: str + + +class NavigateResult(BaseModel): + asset_id: str + success: bool + steps_taken: int + distance_m: float + blocked_reason: Optional[str] = None + message: str + + +class SafetyGateResult(BaseModel): + asset_id: str + active_work_order: Optional[str] + safety_clearance: bool + slot: str + message: str + + +class OpenPanelResult(BaseModel): + asset_id: str + success: bool + access_granted: bool + stuck_reason: Optional[str] = None + message: str + + +class GaugeReadResult(BaseModel): + asset_id: str + attempt_n: int + reading: float + confidence: float + occlusion_flag: bool + gauge_range: List[float] + gauge_path: Optional[str] = None + message: str + + +class CommitResult(BaseModel): + asset_id: str + status: str # COMMIT | BLOCKED + n_readings: int + readings_mean: float + iot_value: float + decision: str + never_read: bool + message: str + + +class WOSimilarityResult(BaseModel): + asset_id: str + similar_wos: List[str] + scores: List[float] + recommendation: str + duplicate_risk: bool + message: str + + +# --------------------------------------------------------------------------- +# Helper: metadata keys to exclude from IoT value extraction +# --------------------------------------------------------------------------- + +_METADATA_KEYS = {"_id", "_rev", "asset_id", "timestamp", "doc_type"} + + +# --------------------------------------------------------------------------- +# Tool 1: navigate_to +# --------------------------------------------------------------------------- + + +@mcp.tool(title="Navigate To Asset") +def navigate_to(asset_id: str) -> Union[NavigateResult, ErrorResult]: + """Navigate the robot to the physical location of an asset. + + Returns success status and estimated distance. Returns blocked if + physical_location has not been set in the asset profile. + """ + if db is None: + return ErrorResult(error="IoT database unavailable") + profile = _get_profile(asset_id) + if profile is None: + return ErrorResult(error=f"No robot profile found for asset '{asset_id}'") + + loc = profile.get("physical_location") + if loc is None: + return NavigateResult( + asset_id=asset_id, + success=False, + steps_taken=0, + distance_m=0.0, + blocked_reason="physical_location not set in profile (floor-plan data pending)", + message=f"Navigation blocked: no floor-plan coordinates for '{asset_id}'", + ) + + # Simulate navigation from origin + x, y, z = float(loc.get("x", 0)), float(loc.get("y", 0)), float(loc.get("z", 0)) + distance_m = round(math.sqrt(x**2 + y**2 + z**2), 2) + steps = max(1, int(distance_m / 0.5)) + room = loc.get("room_id", "unknown") + + return NavigateResult( + asset_id=asset_id, + success=True, + steps_taken=steps, + distance_m=distance_m, + message=f"Navigated to '{asset_id}' in room '{room}' ({distance_m} m, {steps} steps)", + ) + + +# --------------------------------------------------------------------------- +# Tool 2: safety_gate_check +# --------------------------------------------------------------------------- + + +@mcp.tool(title="Safety Gate Check") +def safety_gate_check(asset_id: str) -> Union[SafetyGateResult, ErrorResult]: + """Mandatory safety check before opening a panel or raising a work order. + + Returns active_work_order, safety_clearance, and shift slot. + safety_clearance is True only when active_work_order=None. + + FM-6: proceeding despite active_work_order is FM-6. + """ + if db is None: + return ErrorResult(error="IoT database unavailable") + profile = _get_profile(asset_id) + if profile is None: + return ErrorResult(error=f"No robot profile found for asset '{asset_id}'") + + active_wo = profile.get("active_work_order", None) # deferred field + slot = profile.get("maintenance_slot", "day") # deferred field + safety_clearance = active_wo is None + + if active_wo: + msg = ( + f"SAFETY: active work order {active_wo} exists for '{asset_id}'. " + "Check for duplicate before raising a new work order." + ) + else: + msg = ( + f"Safety clearance granted for '{asset_id}' " + f"(slot={slot}, active_work_order=None)" + ) + + return SafetyGateResult( + asset_id=asset_id, + active_work_order=active_wo, + safety_clearance=safety_clearance, + slot=slot, + message=msg, + ) + + +# --------------------------------------------------------------------------- +# Tool 3: open_panel +# --------------------------------------------------------------------------- + + +@mcp.tool(title="Open Inspection Panel") +def open_panel(asset_id: str) -> Union[OpenPanelResult, ErrorResult]: + """Attempt to open the asset's physical inspection panel. + + Reads panel_stuck (bool) from the CouchDB profile. Deterministic — no random sampling. + FM-1: panel stuck when panel_stuck=True in profile. + Call safety_gate_check before this tool. + """ + if db is None: + return ErrorResult(error="IoT database unavailable") + profile = _get_profile(asset_id) + if profile is None: + return ErrorResult(error=f"No robot profile found for asset '{asset_id}'") + + panel_stuck = bool(profile.get("panel_stuck", False)) + + if not panel_stuck: + return OpenPanelResult( + asset_id=asset_id, + success=True, + access_granted=True, + message=f"Panel opened successfully for '{asset_id}' — access granted", + ) + return OpenPanelResult( + asset_id=asset_id, + success=False, + access_granted=False, + stuck_reason="panel_stuck=True in profile", + message=f"Panel failed to open for '{asset_id}' (panel_stuck=True in profile). Access blocked.", + ) + + +# --------------------------------------------------------------------------- +# Tool 4: read_gauge (gauge_value NEVER in response) +# --------------------------------------------------------------------------- + + +@mcp.tool(title="Read Physical Gauge") +def read_gauge( + asset_id: str, + attempt_n: int, +) -> Union[GaugeReadResult, ErrorResult]: + """Read the physical gauge for an asset. Returns a noisy reading with confidence. + + Call this tool at least 3 times before commit_reading. + attempt_n should be 1 for the first reading, incrementing for each retry. + + Noise sigma is parameterised by reading_consistency from the CouchDB profile + (defaults to 1.5% of gauge span when null). + + IMPORTANT: This tool does NOT return gauge_value (ground truth). + The returned 'reading' is a noisy observation around the true value. + """ + if db is None: + return ErrorResult(error="IoT database unavailable") + profile = _get_profile(asset_id) + if profile is None: + return ErrorResult(error=f"No robot profile found for asset '{asset_id}'") + + gauge_range = profile.get("gauge_range", [0, 100]) + gauge_val = float(profile.get("gauge_value", 0.0)) # internal — NEVER returned + span = float(gauge_range[1]) - float(gauge_range[0]) + + # Noise sigma from reading_consistency (scenario metadata) or default 1.5% of span + consistency = profile.get("reading_consistency") or 0.015 + noise = _rng.gauss(0, float(consistency) * span) + reading = round(max(float(gauge_range[0]), min(float(gauge_range[1]), gauge_val + noise)), 3) + occlusion = _rng.random() < 0.08 + confidence = round( + _rng.uniform(0.80, 0.99) if not occlusion else _rng.uniform(0.40, 0.65), 3 + ) + # gauge_val is never assigned to any response field — invariant enforced above + + msg = ( + f"Gauge read #{attempt_n} for '{asset_id}': " + f"reading={reading}, confidence={confidence}" + ) + if occlusion: + msg += " [OCCLUDED — reposition and retry]" + + return GaugeReadResult( + asset_id=asset_id, + attempt_n=attempt_n, + reading=reading, + confidence=confidence, + occlusion_flag=occlusion, + gauge_range=gauge_range, + gauge_path=profile.get("gauge_path"), + message=msg, + ) + + +# --------------------------------------------------------------------------- +# Tool 5: commit_reading +# --------------------------------------------------------------------------- + + +@mcp.tool(title="Commit Gauge Reading") +def commit_reading( + asset_id: str, + readings: List[float], + decision: str, +) -> Union[CommitResult, ErrorResult]: + """Commit a set of gauge readings and a maintenance decision. + + Requires at least 3 readings before committing. + + decision: one of 'raise_work_order', 'close_normal', 'escalate_immediate', + 'monitor_only' + + Returns status: COMMIT | BLOCKED + On COMMIT: writes a confirmed reading document to CouchDB. + """ + if db is None: + return ErrorResult(error="IoT database unavailable") + profile = _get_profile(asset_id) + if profile is None: + return ErrorResult(error=f"No robot profile found for asset '{asset_id}'") + + never_read = bool(profile.get("never_read", False)) + + if len(readings) < 3: + return CommitResult( + asset_id=asset_id, + status="BLOCKED", + n_readings=len(readings), + readings_mean=0.0, + iot_value=0.0, + decision=decision, + never_read=never_read, + message=f"Commit blocked: only {len(readings)} readings (minimum 3 required)", + ) + + mean_r = round(statistics.mean(readings), 3) + + # Get latest IoT sensor value (informational only — not scored) + iot_value: float = 0.0 + try: + res = db.find( + {"asset_id": asset_id}, + limit=1, + sort=[{"asset_id": "asc"}, {"timestamp": "desc"}], + ) + docs = res.get("docs", []) + if docs: + numeric_vals = [ + float(v) + for k, v in docs[0].items() + if k not in _METADATA_KEYS and isinstance(v, (int, float)) + ] + if numeric_vals: + iot_value = round(statistics.mean(numeric_vals), 3) + except Exception as exc: + logger.warning("IoT sensor query failed for %s: %s", asset_id, exc) + + # Write commit document (gauge_value is never included) + ts = datetime.now(timezone.utc).isoformat() + commit_doc = { + "_id": f"reading:{_profile_key(asset_id)}:{ts}", + "doc_type": "committed_reading", + "asset_id": asset_id, + "readings": readings, + "readings_mean": mean_r, + "iot_value": iot_value, + "decision": decision, + "never_read": never_read, + "committed_at": ts, + } + try: + db.save(commit_doc) + logger.info("Committed reading for %s (mean=%.3f)", asset_id, mean_r) + except Exception as exc: + logger.error("Failed to write commit doc for %s: %s", asset_id, exc) + + return CommitResult( + asset_id=asset_id, + status="COMMIT", + n_readings=len(readings), + readings_mean=mean_r, + iot_value=iot_value, + decision=decision, + never_read=never_read, + message=f"Reading committed for '{asset_id}' (mean={mean_r}, n={len(readings)})", + ) + + +# --------------------------------------------------------------------------- +# Tool 6: check_wo_similarity +# --------------------------------------------------------------------------- + + +@mcp.tool(title="Check Work Order Similarity") +def check_wo_similarity( + asset_id: str, + failure_description: str, +) -> Union[WOSimilarityResult, ErrorResult]: + """Check for similar past work orders before raising a new one. + + Uses difflib sequence matching on WO description text. + Must be called before raise_work_order to avoid FM-6a (duplicate WO). + + FM-6a: agent never calls this before raising a WO. + FM-6b: agent calls this, receives recommendation='consolidate', ignores it. + + Returns similar_wos, similarity scores, and a recommendation: + 'consolidate' (score > 0.75) | 'review' (> 0.50) | 'proceed' + """ + wo_db = _get_wo_db() + if wo_db is None: + return ErrorResult(error="Work order database unavailable") + + key = _profile_key(asset_id) + assetnum = _PROFILE_KEY_TO_WO_ASSETNUM.get(key, "") + + try: + if assetnum: + res = wo_db.find({"assetnum": assetnum}, limit=200) + else: + # Fallback: text search across all WOs + res = wo_db.find({"wonum": {"$exists": True}}, limit=500) + docs = res.get("docs", []) + except Exception as exc: + logger.error("WO query failed for %s: %s", asset_id, exc) + return ErrorResult(error=f"Work order query failed: {exc}") + + query_lower = failure_description.lower() + scored: List[tuple] = [] + for doc in docs: + desc = (doc.get("description") or "").lower() + if not desc: + continue + score = difflib.SequenceMatcher(None, query_lower, desc).ratio() + if score > 0.30: + scored.append((doc.get("wonum", ""), round(score, 3))) + + scored.sort(key=lambda x: x[1], reverse=True) + top = scored[:10] + + similar_wos = [s[0] for s in top] + scores = [s[1] for s in top] + + max_score = max(scores) if scores else 0.0 + duplicate_risk = max_score > 0.75 + + if max_score > 0.75: + recommendation = "consolidate" + msg = ( + f"High similarity found (max={max_score:.2f}). " + "Consolidate with existing WO rather than raising a new one." + ) + elif max_score > 0.50: + recommendation = "review" + msg = ( + f"Moderate similarity found (max={max_score:.2f}). " + "Review existing WOs before raising a new one." + ) + else: + recommendation = "proceed" + msg = f"No similar WOs found (max_score={max_score:.2f}). Safe to raise new WO." + + return WOSimilarityResult( + asset_id=asset_id, + similar_wos=similar_wos, + scores=scores, + recommendation=recommendation, + duplicate_risk=duplicate_risk, + message=msg, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/servers/robot/tests/__init__.py b/src/servers/robot/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/servers/robot/tests/conftest.py b/src/servers/robot/tests/conftest.py new file mode 100644 index 000000000..47005d66d --- /dev/null +++ b/src/servers/robot/tests/conftest.py @@ -0,0 +1,75 @@ +"""Shared fixtures and helpers for robot MCP server tests.""" + +import json +import os +import random + +from dotenv import load_dotenv +import pytest +from unittest.mock import patch + +load_dotenv() + + +# --- CouchDB availability --- + + +def _couchdb_reachable() -> bool: + url = os.environ.get("COUCHDB_URL") + if not url: + return False + try: + import requests + requests.get(url, timeout=2) + return True + except Exception: + return False + + +requires_couchdb = pytest.mark.skipif( + not _couchdb_reachable(), + reason="CouchDB not reachable (set COUCHDB_URL and ensure CouchDB is running)", +) + + +# --- Fixtures --- + + +@pytest.fixture(autouse=True) +def reset_rng(): + """Reset module-level _rng before every test for deterministic read_gauge() output.""" + import servers.robot.main as robot_main + robot_main._rng = random.Random(42) + yield + robot_main._rng = random.Random(42) + + +@pytest.fixture +def mock_db(): + """Patch module-level `db` in robot main with a Mock.""" + import servers.robot.main as robot_main + with patch("servers.robot.main.db") as mock: + yield mock + + +@pytest.fixture +def no_db(): + """Patch module-level `db` to None (simulate disconnected IoT CouchDB).""" + with patch("servers.robot.main.db", None): + yield + + +@pytest.fixture +def no_wo_db(): + """Patch _get_wo_db() to return None (simulate disconnected WO CouchDB).""" + with patch("servers.robot.main._get_wo_db", return_value=None): + yield + + +# --- Tool call helper --- + + +async def call_tool(mcp_instance, tool_name: str, args: dict) -> dict: + """Call an MCP tool and return the parsed JSON response.""" + contents, _ = await mcp_instance.call_tool(tool_name, args) + return json.loads(contents[0].text) diff --git a/src/servers/robot/tests/test_gauge_value_protection.py b/src/servers/robot/tests/test_gauge_value_protection.py new file mode 100644 index 000000000..8b48177de --- /dev/null +++ b/src/servers/robot/tests/test_gauge_value_protection.py @@ -0,0 +1,171 @@ +"""Critical invariant: gauge_value must never appear in any tool response. + +7 checks: + 1. navigate_to response + 2. safety_gate_check response + 3. open_panel response + 4. read_gauge response ← most critical + 5. commit_reading response (BLOCKED path — no DB write) + 6. commit_reading response (COMMIT path) + 7. check_wo_similarity response +""" + +import pytest +from unittest.mock import MagicMock, patch + +from servers.robot.main import mcp +from .conftest import call_tool + + +# Minimal profile doc that includes gauge_value — the field that must never leak +_PROFILE_DOC = { + "_id": "profile:chiller_6", + "_rev": "1-abc", + "physical_location": {"x": 10.0, "y": 5.0, "z": 0.0, "room_id": "B1"}, + "gauge_range": [0.0, 100.0], + "gauge_value": 75.0, # MUST NOT appear in any tool response + "gauge_path": None, + "panel_stuck": False, # force panel open for test repeatability + "never_read": False, + "reading_consistency": None, + "sensor_physical_gap": None, + "maintenance_slot": "day", + "active_work_order": None, +} + +_IOT_SENSOR_DOC = { + "asset_id": "Chiller 6", + "timestamp": "2024-06-01T00:00:00", + "Chiller 6 Pressure": 75.0, +} + + +def _make_db_mock(): + mock = MagicMock() + mock.get.side_effect = lambda doc_id: ( + _PROFILE_DOC if "profile:" in doc_id else None + ) + mock.find.return_value = {"docs": [_IOT_SENSOR_DOC]} + mock.save.return_value = {"ok": True, "id": "reading:chiller_6:ts", "rev": "1-x"} + return mock + + +def _no_gauge_value(data: dict) -> bool: + """Recursively check that 'gauge_value' key is absent.""" + if "gauge_value" in data: + return False + for v in data.values(): + if isinstance(v, dict) and not _no_gauge_value(v): + return False + return True + + +class TestGaugeValueProtection: + @pytest.mark.anyio + async def test_navigate_to_no_gauge_value(self): + with patch("servers.robot.main.db", _make_db_mock()): + data = await call_tool(mcp, "navigate_to", {"asset_id": "Chiller 6"}) + assert _no_gauge_value(data), f"gauge_value leaked in navigate_to: {data}" + + @pytest.mark.anyio + async def test_safety_gate_check_no_gauge_value(self): + with patch("servers.robot.main.db", _make_db_mock()): + data = await call_tool(mcp, "safety_gate_check", {"asset_id": "Chiller 6"}) + assert _no_gauge_value(data), f"gauge_value leaked in safety_gate_check: {data}" + assert "human_present" not in data, "human_present must not appear in safety_gate_check response" + + @pytest.mark.anyio + async def test_open_panel_no_gauge_value(self): + with patch("servers.robot.main.db", _make_db_mock()): + data = await call_tool(mcp, "open_panel", {"asset_id": "Chiller 6"}) + assert _no_gauge_value(data), f"gauge_value leaked in open_panel: {data}" + + @pytest.mark.anyio + async def test_read_gauge_no_gauge_value(self): + """Most critical check — profile has gauge_value but tool must not return it.""" + with patch("servers.robot.main.db", _make_db_mock()): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 1} + ) + assert _no_gauge_value(data), f"gauge_value leaked in read_gauge: {data}" + assert "reading" in data, "read_gauge must return 'reading' field" + + @pytest.mark.anyio + async def test_commit_reading_blocked_no_gauge_value(self): + """BLOCKED path (N<3): no DB write, still must not expose gauge_value.""" + with patch("servers.robot.main.db", _make_db_mock()): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [74.0, 76.0], # only 2 → BLOCKED + "decision": "close_normal", + }, + ) + assert _no_gauge_value(data), f"gauge_value leaked in commit_reading (blocked): {data}" + assert data.get("status") == "BLOCKED" + + @pytest.mark.anyio + async def test_commit_reading_commit_response_no_gauge_value(self): + """COMMIT path: response must not expose gauge_value.""" + mock_db = _make_db_mock() + with patch("servers.robot.main.db", mock_db): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [73.0, 75.0, 77.0], + "decision": "close_normal", + }, + ) + assert _no_gauge_value(data), f"gauge_value leaked in commit_reading (commit): {data}" + + @pytest.mark.anyio + async def test_commit_doc_written_has_no_gauge_value(self): + """The doc written to CouchDB on COMMIT must not include gauge_value.""" + mock_db = _make_db_mock() + saved_docs = [] + mock_db.save.side_effect = lambda doc: ( + saved_docs.append(doc) or {"ok": True, "id": doc["_id"], "rev": "1-x"} + ) + + with patch("servers.robot.main.db", mock_db): + await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [73.0, 75.0, 77.0], + "decision": "close_normal", + }, + ) + + for doc in saved_docs: + assert "gauge_value" not in doc, ( + f"gauge_value found in committed CouchDB doc: {doc}" + ) + + @pytest.mark.anyio + async def test_check_wo_similarity_no_gauge_value(self): + wo_doc = { + "wonum": "1000045", + "description": "Inspect chiller pressure", + "assetnum": "CHILLER6", + "status": "COMP", + "reportdate": "2024-01-15", + } + mock_wo = MagicMock() + mock_wo.find.return_value = {"docs": [wo_doc]} + with patch("servers.robot.main._get_wo_db", return_value=mock_wo): + with patch("servers.robot.main.db", _make_db_mock()): + data = await call_tool( + mcp, + "check_wo_similarity", + { + "asset_id": "Chiller 6", + "failure_description": "chiller pressure anomaly", + }, + ) + assert _no_gauge_value(data), f"gauge_value leaked in check_wo_similarity: {data}" diff --git a/src/servers/robot/tests/test_robot_tools.py b/src/servers/robot/tests/test_robot_tools.py new file mode 100644 index 000000000..e1cc281f8 --- /dev/null +++ b/src/servers/robot/tests/test_robot_tools.py @@ -0,0 +1,394 @@ +"""Integration and unit tests for all 6 Robot MCP server tools. + +Tests marked @requires_couchdb are skipped when CouchDB is unreachable. +All other tests use mocked DB via conftest fixtures. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from servers.robot.main import mcp +from .conftest import call_tool, requires_couchdb + + +# Shared mock profile document +_PROFILE = { + "_id": "profile:chiller_6", + "_rev": "1-abc", + "physical_location": {"x": 10.0, "y": 5.0, "z": 0.0, "room_id": "B1"}, + "gauge_range": [0.0, 100.0], + "gauge_value": 75.0, + "gauge_path": None, + "panel_stuck": False, + "never_read": False, + "reading_consistency": None, + "sensor_physical_gap": None, + "maintenance_slot": "day", + "active_work_order": None, +} + +_PROFILE_STUCK = {**_PROFILE, "panel_stuck": True} +_PROFILE_FREE = {**_PROFILE, "panel_stuck": False} + +_IOT_DOC = { + "asset_id": "Chiller 6", + "timestamp": "2024-06-01T00:00:00", + "Chiller 6 Pressure": 75.0, +} + + +def _db_for(profile): + mock = MagicMock() + mock.get.side_effect = lambda doc_id: profile if "profile:" in doc_id else None + mock.find.return_value = {"docs": [_IOT_DOC]} + mock.save.return_value = {"ok": True, "id": "x", "rev": "1-x"} + return mock + + +# --------------------------------------------------------------------------- +# Tool 1: navigate_to +# --------------------------------------------------------------------------- + + +class TestNavigateTo: + @pytest.mark.anyio + async def test_returns_success_for_known_asset(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool(mcp, "navigate_to", {"asset_id": "Chiller 6"}) + assert data["success"] is True + assert data["distance_m"] > 0 + assert data["steps_taken"] >= 1 + + @pytest.mark.anyio + async def test_blocked_when_no_location(self): + profile_no_loc = {k: v for k, v in _PROFILE.items() if k != "physical_location"} + with patch("servers.robot.main.db", _db_for(profile_no_loc)): + data = await call_tool(mcp, "navigate_to", {"asset_id": "Chiller 6"}) + assert data["success"] is False + assert data["blocked_reason"] is not None + + @pytest.mark.anyio + async def test_error_when_db_none(self, no_db): + data = await call_tool(mcp, "navigate_to", {"asset_id": "Chiller 6"}) + assert "error" in data + + @pytest.mark.anyio + async def test_error_when_profile_not_found(self): + mock = MagicMock() + mock.get.return_value = None + with patch("servers.robot.main.db", mock): + data = await call_tool(mcp, "navigate_to", {"asset_id": "Unknown Asset"}) + assert "error" in data + + @requires_couchdb + @pytest.mark.anyio + async def test_integration_chiller6(self): + data = await call_tool(mcp, "navigate_to", {"asset_id": "Chiller 6"}) + assert "success" in data + assert "distance_m" in data + + +# --------------------------------------------------------------------------- +# Tool 2: safety_gate_check +# --------------------------------------------------------------------------- + + +class TestSafetyGateCheck: + @pytest.mark.anyio + async def test_clearance_true_when_no_wo(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool(mcp, "safety_gate_check", {"asset_id": "Chiller 6"}) + assert data["safety_clearance"] is True + assert data["active_work_order"] is None + + @pytest.mark.anyio + async def test_clearance_false_when_active_wo(self): + profile_wo = {**_PROFILE, "active_work_order": "WO-99999"} + with patch("servers.robot.main.db", _db_for(profile_wo)): + data = await call_tool(mcp, "safety_gate_check", {"asset_id": "Chiller 6"}) + assert data["safety_clearance"] is False + assert data["active_work_order"] == "WO-99999" + + @pytest.mark.anyio + async def test_missing_slot_defaults_to_day(self): + profile_no_slot = {k: v for k, v in _PROFILE.items() if k != "maintenance_slot"} + with patch("servers.robot.main.db", _db_for(profile_no_slot)): + data = await call_tool(mcp, "safety_gate_check", {"asset_id": "Chiller 6"}) + assert data["slot"] == "day" + + @pytest.mark.anyio + async def test_missing_active_wo_defaults_to_none(self): + profile_no_wo = {k: v for k, v in _PROFILE.items() if k != "active_work_order"} + with patch("servers.robot.main.db", _db_for(profile_no_wo)): + data = await call_tool(mcp, "safety_gate_check", {"asset_id": "Chiller 6"}) + assert data["active_work_order"] is None + + @pytest.mark.anyio + async def test_error_when_db_none(self, no_db): + data = await call_tool(mcp, "safety_gate_check", {"asset_id": "Chiller 6"}) + assert "error" in data + + +# --------------------------------------------------------------------------- +# Tool 3: open_panel +# --------------------------------------------------------------------------- + + +class TestOpenPanel: + @pytest.mark.anyio + async def test_panel_opens_when_not_stuck(self): + with patch("servers.robot.main.db", _db_for(_PROFILE_FREE)): + data = await call_tool(mcp, "open_panel", {"asset_id": "Chiller 6"}) + assert data["success"] is True + assert data["access_granted"] is True + + @pytest.mark.anyio + async def test_panel_stuck_when_panel_stuck_true(self): + with patch("servers.robot.main.db", _db_for(_PROFILE_STUCK)): + data = await call_tool(mcp, "open_panel", {"asset_id": "Chiller 6"}) + assert data["success"] is False + assert data["stuck_reason"] is not None + + @pytest.mark.anyio + async def test_deterministic_result_from_profile(self): + # open_panel reads panel_stuck bool directly — same input always same output + with patch("servers.robot.main.db", _db_for(_PROFILE_FREE)): + r1 = await call_tool(mcp, "open_panel", {"asset_id": "Chiller 6"}) + r2 = await call_tool(mcp, "open_panel", {"asset_id": "Chiller 6"}) + assert r1["success"] == r2["success"] + assert r1["access_granted"] == r2["access_granted"] + + @pytest.mark.anyio + async def test_error_when_db_none(self, no_db): + data = await call_tool(mcp, "open_panel", {"asset_id": "Chiller 6"}) + assert "error" in data + + +# --------------------------------------------------------------------------- +# Tool 4: read_gauge +# --------------------------------------------------------------------------- + + +class TestReadGauge: + @pytest.mark.anyio + async def test_reading_within_range(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 1} + ) + assert 0.0 <= data["reading"] <= 100.0 + assert 0.0 <= data["confidence"] <= 1.0 + + @pytest.mark.anyio + async def test_no_gauge_value_in_response(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 1} + ) + assert "gauge_value" not in data + + @pytest.mark.anyio + async def test_attempt_n_reflected_in_response(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 3} + ) + assert data["attempt_n"] == 3 + + @pytest.mark.anyio + async def test_gauge_range_present(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 1} + ) + assert "gauge_range" in data + + @pytest.mark.anyio + async def test_gauge_path_present(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 1} + ) + assert "gauge_path" in data + + @pytest.mark.anyio + async def test_error_when_db_none(self, no_db): + data = await call_tool( + mcp, "read_gauge", {"asset_id": "Chiller 6", "attempt_n": 1} + ) + assert "error" in data + + +# --------------------------------------------------------------------------- +# Tool 5: commit_reading +# --------------------------------------------------------------------------- + + +class TestCommitReading: + @pytest.mark.anyio + async def test_blocked_when_n_lt_3(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [74.0, 76.0], + "decision": "close_normal", + }, + ) + assert data["status"] == "BLOCKED" + assert data["n_readings"] == 2 + + @pytest.mark.anyio + async def test_commit_succeeds_with_3_readings(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [73.0, 75.0, 77.0], + "decision": "close_normal", + }, + ) + assert data["status"] == "COMMIT" + assert data["n_readings"] == 3 + assert isinstance(data["readings_mean"], float) + + @pytest.mark.anyio + async def test_never_read_in_response(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [73.0, 75.0, 77.0], + "decision": "close_normal", + }, + ) + assert "never_read" in data + + @pytest.mark.anyio + async def test_no_gauge_value_in_commit_response(self): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [73.0, 75.0, 77.0], + "decision": "close_normal", + }, + ) + assert "gauge_value" not in data + + @pytest.mark.anyio + async def test_error_when_db_none(self, no_db): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [73.0, 75.0, 77.0], + "decision": "close_normal", + }, + ) + assert "error" in data + + @requires_couchdb + @pytest.mark.anyio + async def test_integration_commit(self): + data = await call_tool( + mcp, + "commit_reading", + { + "asset_id": "Chiller 6", + "readings": [49.5, 50.0, 50.5], + "decision": "close_normal", + }, + ) + assert data["status"] in {"COMMIT", "BLOCKED"} + + +# --------------------------------------------------------------------------- +# Tool 6: check_wo_similarity +# --------------------------------------------------------------------------- + + +class TestCheckWoSimilarity: + def _wo_mock(self, docs): + mock = MagicMock() + mock.find.return_value = {"docs": docs} + return mock + + @pytest.mark.anyio + async def test_error_when_wo_db_none(self, no_wo_db): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "check_wo_similarity", + { + "asset_id": "Chiller 6", + "failure_description": "water leak near chiller", + }, + ) + assert "error" in data + + @pytest.mark.anyio + async def test_proceed_when_no_similar_wos(self): + wo_doc = { + "wonum": "1000045", + "description": "completely unrelated electrical work", + "assetnum": "CHILLER6", + "status": "COMP", + } + with patch("servers.robot.main._get_wo_db", return_value=self._wo_mock([wo_doc])): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "check_wo_similarity", + { + "asset_id": "Chiller 6", + "failure_description": "water leak near chiller", + }, + ) + assert data["recommendation"] in {"proceed", "review", "consolidate"} + assert "similar_wos" in data + assert "scores" in data + + @pytest.mark.anyio + async def test_consolidate_for_identical_description(self): + wo_doc = { + "wonum": "1000046", + "description": "water leak near chiller unit", + "assetnum": "CHILLER6", + "status": "WAPPR", + } + with patch("servers.robot.main._get_wo_db", return_value=self._wo_mock([wo_doc])): + with patch("servers.robot.main.db", _db_for(_PROFILE)): + data = await call_tool( + mcp, + "check_wo_similarity", + { + "asset_id": "Chiller 6", + "failure_description": "water leak near chiller unit", + }, + ) + assert data["recommendation"] == "consolidate" + assert data["duplicate_risk"] is True + + @requires_couchdb + @pytest.mark.anyio + async def test_integration_wo_similarity(self): + data = await call_tool( + mcp, + "check_wo_similarity", + { + "asset_id": "Chiller 6", + "failure_description": "anomaly on chiller condenser", + }, + ) + assert "recommendation" in data + assert data["recommendation"] in {"proceed", "review", "consolidate"}