From 6b7798da6b8f4955be199c99294bd10f1d94abd2 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Mon, 6 Jul 2026 18:10:48 -0400 Subject: [PATCH 01/16] feat: add catalog utility tools Signed-off-by: Shuxin Lin --- src/servers/utilities/main.py | 152 +++++++++++++++++- src/servers/utilities/tests/test_utilities.py | 111 +++++++++++++ 2 files changed, 261 insertions(+), 2 deletions(-) diff --git a/src/servers/utilities/main.py b/src/servers/utilities/main.py index 42858783c..ea6dc9178 100644 --- a/src/servers/utilities/main.py +++ b/src/servers/utilities/main.py @@ -1,15 +1,19 @@ import json import logging +import os import tempfile from datetime import datetime, timezone from pathlib import Path +from typing import Any, Optional, Union from uuid import uuid4 +import couchdb3 import pendulum +from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP from pydantic import BaseModel -import os +load_dotenv() # Setup logging — default WARNING so stderr stays quiet when used as MCP server; # set LOG_LEVEL=INFO (or DEBUG) in the environment to see verbose output. @@ -19,9 +23,30 @@ logging.basicConfig(level=_log_level) logger = logging.getLogger("utilities-mcp-server") +# Configuration from environment +COUCHDB_URL = os.environ.get("COUCHDB_URL", "http://localhost:5984") +COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME", "admin") +COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD", "password") +CATALOG_DBNAME = os.environ.get("CATALOG_DBNAME", "catalog") + +try: + catalog_db = couchdb3.Database( + CATALOG_DBNAME, + url=COUCHDB_URL, + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + ) + logger.info("Connected to CouchDB catalog database: %s", CATALOG_DBNAME) +except Exception as e: + logger.error("Failed to connect to CouchDB catalog database: %s", e) + catalog_db = None + mcp = FastMCP( "utilities", - instructions="General utilities: read JSON files and get current date/time.", + instructions=( + "General utilities: read JSON files, get current date/time, and query " + "asset, sensor, and failure-mode catalog data from CouchDB." + ), ) @@ -35,6 +60,18 @@ class TimeEnglishResult(BaseModel): iso: str +class ErrorResult(BaseModel): + error: str + + +class CatalogResult(BaseModel): + catalog_type: str + query: Optional[str] + total: int + entries: list[dict[str, Any]] + message: str + + # --- Helper Functions --- @@ -51,6 +88,62 @@ def get_temp_filename() -> str: return str(filepath) +def _clean_filter(value: Optional[str]) -> Optional[str]: + if value is None: + return None + value = value.strip() + return value or None + + +def _catalog_limit(limit: int) -> int: + return max(1, min(limit, 5000)) + + +def _find_catalog( + *, + catalog_type: str, + field: str, + value: Optional[str], + fields: list[str], + limit: int, + category: Optional[str] = None, +) -> Union[CatalogResult, ErrorResult]: + if catalog_db is None: + return ErrorResult(error="catalog database is not available") + + query_value = _clean_filter(value) + category_value = _clean_filter(category) + selector: dict[str, Any] = {field: query_value or {"$exists": True}} + if category_value is not None: + selector["category"] = category_value + + try: + res = catalog_db.find( + selector, + fields=fields, + limit=_catalog_limit(limit), + ) + docs = res.get("docs", []) + except Exception as e: + logger.error("Error querying %s catalog: %s", catalog_type, e) + return ErrorResult(error=str(e)) + + query_parts = [] + if query_value is not None: + query_parts.append(query_value) + if category_value is not None: + query_parts.append(f"category={category_value}") + query = ", ".join(query_parts) if query_parts else None + + return CatalogResult( + catalog_type=catalog_type, + query=query, + total=len(docs), + entries=docs, + message=f"found {len(docs)} {catalog_type} catalog entries", + ) + + # --- JSON Tools --- @@ -66,6 +159,61 @@ def json_reader(file_name: str) -> str: return json.dumps({"error": str(e)}) +# --- Catalog Tools --- + + +@mcp.tool(title="Get Sensor Catalog") +def get_sensor_catalog( + sensor: Optional[str] = None, + limit: int = 1000, +) -> Union[CatalogResult, ErrorResult]: + """Return sensor catalog entries. Pass sensor for an exact sensor-name lookup.""" + return _find_catalog( + catalog_type="sensor", + field="sensor", + value=sensor, + fields=["sensor", "description"], + limit=limit, + ) + + +@mcp.tool(title="Get Asset Catalog") +def get_asset_catalog( + asset: Optional[str] = None, + category: Optional[str] = None, + limit: int = 1000, +) -> Union[CatalogResult, ErrorResult]: + """Return asset catalog entries. Pass asset and/or category for exact lookups.""" + return _find_catalog( + catalog_type="asset", + field="asset", + value=asset, + category=category, + fields=["category", "category_description", "asset", "description"], + limit=limit, + ) + + +@mcp.tool(title="Get Failure Model Catalog") +def get_failure_model_catalog( + failure_mode: Optional[str] = None, + category: Optional[str] = None, + limit: int = 1000, +) -> Union[CatalogResult, ErrorResult]: + """Return failure-mode catalog entries. + + Pass failure_mode and/or category for exact lookups. + """ + return _find_catalog( + catalog_type="failure_mode", + field="failure_mode", + value=failure_mode, + category=category, + fields=["category", "failure_mode", "description"], + limit=limit, + ) + + # --- Time Tools --- diff --git a/src/servers/utilities/tests/test_utilities.py b/src/servers/utilities/tests/test_utilities.py index 0940ea266..2d287a050 100644 --- a/src/servers/utilities/tests/test_utilities.py +++ b/src/servers/utilities/tests/test_utilities.py @@ -5,10 +5,61 @@ import tempfile import pytest +from servers.utilities import main as utilities from servers.utilities.main import mcp from .conftest import call_tool +class FakeCatalogDB: + def __init__(self, docs): + self.docs = docs + self.calls = [] + + def find(self, selector, fields=None, limit=200): + self.calls.append({"selector": selector, "fields": fields, "limit": limit}) + matched = [] + for doc in self.docs: + if self._matches(doc, selector): + if fields: + matched.append( + {field: doc[field] for field in fields if field in doc} + ) + else: + matched.append(doc) + return {"docs": matched[:limit]} + + def _matches(self, doc, selector): + for field, expected in selector.items(): + if isinstance(expected, dict) and "$exists" in expected: + if (field in doc) != expected["$exists"]: + return False + elif doc.get(field) != expected: + return False + return True + + +@pytest.fixture +def fake_catalog_db(monkeypatch): + fake = FakeCatalogDB( + [ + {"sensor": "air flow", "description": "Airflow sensor"}, + { + "category": "rotating equipment", + "category_description": "Rotating equipment", + "asset": "Electric motor", + "description": "Converts electricity into motion.", + }, + { + "category": "rotating equipment", + "failure_mode": "Air inlet blockage", + "description": "Air intake is blocked.", + }, + ] + ) + monkeypatch.setattr(utilities, "catalog_db", fake) + return fake + + # --------------------------------------------------------------------------- # current_date_time # --------------------------------------------------------------------------- @@ -104,3 +155,63 @@ async def test_invalid_json_content(self): assert "error" in data finally: os.remove(tmp_name) + + +# --------------------------------------------------------------------------- +# catalog tools +# --------------------------------------------------------------------------- + + +class TestCatalogTools: + @pytest.mark.anyio + async def test_get_sensor_catalog_lists_sensor_entries(self, fake_catalog_db): + data = await call_tool(mcp, "get_sensor_catalog", {}) + + assert data["catalog_type"] == "sensor" + assert data["total"] == 1 + assert data["entries"] == [ + {"sensor": "air flow", "description": "Airflow sensor"} + ] + assert fake_catalog_db.calls[-1]["selector"] == { + "sensor": {"$exists": True} + } + + @pytest.mark.anyio + async def test_get_asset_catalog_filters_asset_and_category(self, fake_catalog_db): + data = await call_tool( + mcp, + "get_asset_catalog", + {"asset": "Electric motor", "category": "rotating equipment"}, + ) + + assert data["catalog_type"] == "asset" + assert data["query"] == "Electric motor, category=rotating equipment" + assert data["total"] == 1 + assert data["entries"][0]["asset"] == "Electric motor" + assert fake_catalog_db.calls[-1]["selector"] == { + "asset": "Electric motor", + "category": "rotating equipment", + } + + @pytest.mark.anyio + async def test_get_failure_model_catalog_queries_failure_mode( + self, fake_catalog_db + ): + data = await call_tool( + mcp, + "get_failure_model_catalog", + {"failure_mode": "Air inlet blockage"}, + ) + + assert data["catalog_type"] == "failure_mode" + assert data["total"] == 1 + assert data["entries"] == [ + { + "category": "rotating equipment", + "failure_mode": "Air inlet blockage", + "description": "Air intake is blocked.", + } + ] + assert fake_catalog_db.calls[-1]["selector"] == { + "failure_mode": "Air inlet blockage" + } From e5eb8354d093ff59de454fca65d825377d2803b3 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Mon, 6 Jul 2026 18:23:49 -0400 Subject: [PATCH 02/16] refactor: hide catalog query limit Signed-off-by: Shuxin Lin --- src/servers/utilities/main.py | 14 ++------------ src/servers/utilities/tests/test_utilities.py | 1 + 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/servers/utilities/main.py b/src/servers/utilities/main.py index ea6dc9178..dd940f7cf 100644 --- a/src/servers/utilities/main.py +++ b/src/servers/utilities/main.py @@ -28,6 +28,7 @@ COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME", "admin") COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD", "password") CATALOG_DBNAME = os.environ.get("CATALOG_DBNAME", "catalog") +CATALOG_QUERY_LIMIT = 1000 try: catalog_db = couchdb3.Database( @@ -95,17 +96,12 @@ def _clean_filter(value: Optional[str]) -> Optional[str]: return value or None -def _catalog_limit(limit: int) -> int: - return max(1, min(limit, 5000)) - - def _find_catalog( *, catalog_type: str, field: str, value: Optional[str], fields: list[str], - limit: int, category: Optional[str] = None, ) -> Union[CatalogResult, ErrorResult]: if catalog_db is None: @@ -121,7 +117,7 @@ def _find_catalog( res = catalog_db.find( selector, fields=fields, - limit=_catalog_limit(limit), + limit=CATALOG_QUERY_LIMIT, ) docs = res.get("docs", []) except Exception as e: @@ -165,7 +161,6 @@ def json_reader(file_name: str) -> str: @mcp.tool(title="Get Sensor Catalog") def get_sensor_catalog( sensor: Optional[str] = None, - limit: int = 1000, ) -> Union[CatalogResult, ErrorResult]: """Return sensor catalog entries. Pass sensor for an exact sensor-name lookup.""" return _find_catalog( @@ -173,7 +168,6 @@ def get_sensor_catalog( field="sensor", value=sensor, fields=["sensor", "description"], - limit=limit, ) @@ -181,7 +175,6 @@ def get_sensor_catalog( def get_asset_catalog( asset: Optional[str] = None, category: Optional[str] = None, - limit: int = 1000, ) -> Union[CatalogResult, ErrorResult]: """Return asset catalog entries. Pass asset and/or category for exact lookups.""" return _find_catalog( @@ -190,7 +183,6 @@ def get_asset_catalog( value=asset, category=category, fields=["category", "category_description", "asset", "description"], - limit=limit, ) @@ -198,7 +190,6 @@ def get_asset_catalog( def get_failure_model_catalog( failure_mode: Optional[str] = None, category: Optional[str] = None, - limit: int = 1000, ) -> Union[CatalogResult, ErrorResult]: """Return failure-mode catalog entries. @@ -210,7 +201,6 @@ def get_failure_model_catalog( value=failure_mode, category=category, fields=["category", "failure_mode", "description"], - limit=limit, ) diff --git a/src/servers/utilities/tests/test_utilities.py b/src/servers/utilities/tests/test_utilities.py index 2d287a050..c60e0ff91 100644 --- a/src/servers/utilities/tests/test_utilities.py +++ b/src/servers/utilities/tests/test_utilities.py @@ -175,6 +175,7 @@ async def test_get_sensor_catalog_lists_sensor_entries(self, fake_catalog_db): assert fake_catalog_db.calls[-1]["selector"] == { "sensor": {"$exists": True} } + assert fake_catalog_db.calls[-1]["limit"] == utilities.CATALOG_QUERY_LIMIT @pytest.mark.anyio async def test_get_asset_catalog_filters_asset_and_category(self, fake_catalog_db): From c44b1ad0f4bc1c5ad2772bdf6c90ba624c9777b5 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Tue, 7 Jul 2026 19:41:54 -0400 Subject: [PATCH 03/16] fix: standardize failure code collection key Signed-off-by: Shuxin Lin --- docs/data.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/data.md b/docs/data.md index 52ab82767..259d2c6ec 100644 --- a/docs/data.md +++ b/docs/data.md @@ -21,7 +21,7 @@ docker compose -f src/couchdb/docker-compose.yaml up -d # 2. Verify (Fauxton UI: http://localhost:5984/_utils — admin / password) curl -s -u admin:password http://localhost:5984/_all_dbs -# → ["workorder","iot","vibration","failurecode", ...] +# → ["workorder","iot","vibration","failure_code", ...] curl -s -u admin:password http://localhost:5984/workorder/wo:MAIN:1000045 ``` @@ -133,7 +133,7 @@ init_data() # back to default "workorder": "shared/work_order/workorders.csv", "iot": ["shared/iot/chiller_6.json", "shared/iot/metro_pump_1.json"], "vibration": "shared/iot/motor_01.json", - "failurecode": "shared/failure_code/failure_code_sample.csv" + "failure_code": "shared/failure_code/failure_code_sample.csv" } ``` @@ -154,7 +154,7 @@ Edit this file and re-run `init_data.py` — that's the whole change. ### 2. Change *how* a collection is parsed — `collections.json` ```json -"failurecode": { +"failure_code": { "format": "csv", "primary_key": ["code"], "id_prefix": "fc", @@ -283,4 +283,4 @@ folder's parent); data unique to a private scenario goes in that scenario's fold Consumers should sort client-side or add the index to `collections.json`. - **`--reuse` keeps history; default reload wipes it.** Anything written at runtime (e.g. work orders created by agent tools) is deleted on the next default load — - that's the deterministic-benchmark behavior. \ No newline at end of file + that's the deterministic-benchmark behavior. From a007f72c2134c3969774753eedc30fe295c2109a Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Wed, 8 Jul 2026 14:50:13 -0400 Subject: [PATCH 04/16] fix: rename failure mode catalog tool Add one-row synthetic catalog samples and allowlist the new catalog CSV files. Signed-off-by: Shuxin Lin --- src/servers/utilities/main.py | 4 ++-- src/servers/utilities/tests/test_utilities.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/servers/utilities/main.py b/src/servers/utilities/main.py index dd940f7cf..ffb5157c1 100644 --- a/src/servers/utilities/main.py +++ b/src/servers/utilities/main.py @@ -186,8 +186,8 @@ def get_asset_catalog( ) -@mcp.tool(title="Get Failure Model Catalog") -def get_failure_model_catalog( +@mcp.tool(title="Get Failure Mode Catalog") +def get_failure_mode_catalog( failure_mode: Optional[str] = None, category: Optional[str] = None, ) -> Union[CatalogResult, ErrorResult]: diff --git a/src/servers/utilities/tests/test_utilities.py b/src/servers/utilities/tests/test_utilities.py index c60e0ff91..20e7c9d8d 100644 --- a/src/servers/utilities/tests/test_utilities.py +++ b/src/servers/utilities/tests/test_utilities.py @@ -195,12 +195,12 @@ async def test_get_asset_catalog_filters_asset_and_category(self, fake_catalog_d } @pytest.mark.anyio - async def test_get_failure_model_catalog_queries_failure_mode( + async def test_get_failure_mode_catalog_queries_failure_mode( self, fake_catalog_db ): data = await call_tool( mcp, - "get_failure_model_catalog", + "get_failure_mode_catalog", {"failure_mode": "Air inlet blockage"}, ) From b6a44da2a23860fc2608c3707253c251ac639da3 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Wed, 8 Jul 2026 15:14:37 -0400 Subject: [PATCH 05/16] feat: add failure mode collection sample Add a failure_mode CouchDB collection config and a one-document synthetic FMEA sample. Inline the curated FMSR failure-mode mapping so the obsolete YAML data file can be removed. Signed-off-by: Shuxin Lin --- src/servers/fmsr/failure_modes.yaml | 15 --------------- src/servers/fmsr/main.py | 23 ++++++++++++++++++----- 2 files changed, 18 insertions(+), 20 deletions(-) delete mode 100644 src/servers/fmsr/failure_modes.yaml diff --git a/src/servers/fmsr/failure_modes.yaml b/src/servers/fmsr/failure_modes.yaml deleted file mode 100644 index 16712b8ee..000000000 --- a/src/servers/fmsr/failure_modes.yaml +++ /dev/null @@ -1,15 +0,0 @@ -chiller: - - "Compressor Overheating: Failed due to Normal wear, overheating" - - "Heat Exchangers: Fans: Degraded motor or worn bearing due to Normal use" - - "Evaporator Water side fouling" - - "Condenser Water side fouling" - - "Condenser Improper water side flow rate" - - "Purge Unit Excessive purge" - - "Refrigerant Operated Control Valve Failed spring" - -ahu: - - "Pressure Regulators Diaphragm failure" - - "Steam Heating Coils Air side fouling" - - "Belts or sheaves Wear" - - "Improper switch position" - - "Solenoid Valves Bound due to hardened grease" diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index d858f1587..77e4cbe24 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -18,12 +18,10 @@ import logging import os import re -from pathlib import Path from typing import Dict, List, Union from concurrent.futures import ThreadPoolExecutor, as_completed -import yaml from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP from pydantic import BaseModel @@ -39,9 +37,24 @@ # ── Hardcoded asset data ────────────────────────────────────────────────────── -_FAILURE_MODES_FILE = Path(__file__).parent / "failure_modes.yaml" -with _FAILURE_MODES_FILE.open() as _f: - _ASSET_FAILURE_MODES: dict[str, list[str]] = yaml.safe_load(_f) +_ASSET_FAILURE_MODES: dict[str, list[str]] = { + "chiller": [ + "Compressor Overheating: Failed due to Normal wear, overheating", + "Heat Exchangers: Fans: Degraded motor or worn bearing due to Normal use", + "Evaporator Water side fouling", + "Condenser Water side fouling", + "Condenser Improper water side flow rate", + "Purge Unit Excessive purge", + "Refrigerant Operated Control Valve Failed spring", + ], + "ahu": [ + "Pressure Regulators Diaphragm failure", + "Steam Heating Coils Air side fouling", + "Belts or sheaves Wear", + "Improper switch position", + "Solenoid Valves Bound due to hardened grease", + ], +} # ── Prompt templates ────────────────────────────────────────────────────────── From 49a11a044533dde124e7d4e9028b9488331a8c07 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Wed, 8 Jul 2026 16:18:53 -0400 Subject: [PATCH 06/16] feat: back FMSR failure modes with CouchDB Signed-off-by: Shuxin Lin --- src/servers/fmsr/main.py | 429 +++++++++++++++++++++------ src/servers/fmsr/tests/conftest.py | 102 +++++++ src/servers/fmsr/tests/test_tools.py | 188 +++++++++--- 3 files changed, 589 insertions(+), 130 deletions(-) diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index 77e4cbe24..d66442a9d 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -1,16 +1,20 @@ """FMSR (Failure Mode and Sensor Reasoning) MCP Server. -Exposes two tools: - get_failure_modes – lists failure modes for an asset - get_failure_mode_sensor_mapping – returns bidirectional FM↔sensor relevancy mapping - -For chillers and AHUs get_failure_modes returns a curated hardcoded list. -For any other asset type the LLM is queried as a fallback. -The mapping tool always calls the LLM to determine per-pair relevancy. - -LLM backend is configured via the FMSR_MODEL_ID environment variable -(default: ``watsonx/meta-llama/llama-3-3-70b-instruct``). Any model string -supported by litellm works — the provider is encoded in the prefix. +Tools: + get_failure_modes – READ the (partial) failure-mode list for an asset from CouchDB + generate_failure_modes – GENERATE failure modes via the LLM (when DB is missing/partial) + add_failure_modes – WRITE: persist/augment a class's failure modes in CouchDB + generate_failure_mode_sensor_mapping – GENERATE the bidirectional FM↔sensor relevancy via the LLM + +Failure modes live in CouchDB (collection "failure_mode", doctype "failure_mode", +one doc per asset class, loaded from scenario manifests). Coverage is NOT +exhaustive: docs carry `exhaustive: false`, so get_failure_modes returns what is +known and signals when generation is needed. Retrieval (get_*) and generation +(generate_*) are separate by design. + +LLM backend is configured via FMSR_MODEL_ID (default: watsonx/meta-llama/llama-3-3-70b-instruct). +CouchDB via COUCHDB_URL / FAILURE_MODE_DBNAME / CATALOG_DBNAME / +COUCHDB_USERNAME / COUCHDB_PASSWORD. """ from __future__ import annotations @@ -18,10 +22,11 @@ import logging import os import re -from typing import Dict, List, Union +from typing import Dict, List, Optional, Union from concurrent.futures import ThreadPoolExecutor, as_completed +import couchdb3 from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP from pydantic import BaseModel @@ -35,26 +40,41 @@ logger = logging.getLogger("fmsr-mcp-server") -# ── Hardcoded asset data ────────────────────────────────────────────────────── - -_ASSET_FAILURE_MODES: dict[str, list[str]] = { - "chiller": [ - "Compressor Overheating: Failed due to Normal wear, overheating", - "Heat Exchangers: Fans: Degraded motor or worn bearing due to Normal use", - "Evaporator Water side fouling", - "Condenser Water side fouling", - "Condenser Improper water side flow rate", - "Purge Unit Excessive purge", - "Refrigerant Operated Control Valve Failed spring", - ], - "ahu": [ - "Pressure Regulators Diaphragm failure", - "Steam Heating Coils Air side fouling", - "Belts or sheaves Wear", - "Improper switch position", - "Solenoid Valves Bound due to hardened grease", - ], -} +# ── CouchDB stores ──────────────────────────────────────────────────────────── +# Under AssetOpsBench's loader, database name == collection key, so the FMSR data is two separate +# databases (loaded from the manifest, no seed): 'failure_mode' and 'catalog'. We open one handle +# each — there is no single 'fmsr' database. + +COUCHDB_URL = os.environ.get("COUCHDB_URL", "http://localhost:5984") +COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME", "admin") +COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD", "password") +FAILURE_MODE_DBNAME = os.environ.get("FAILURE_MODE_DBNAME", "failure_mode") +CATALOG_DBNAME = os.environ.get("CATALOG_DBNAME", "catalog") +CATALOG_QUERY_LIMIT = 1000 + + +def _connect(dbname): + try: + h = couchdb3.Database( + dbname, + url=COUCHDB_URL, + user=COUCHDB_USERNAME, + password=COUCHDB_PASSWORD, + ) + logger.info("Connected to CouchDB: %s", dbname) + return h + except Exception as e: # noqa: BLE001 + logger.error("Failed to connect to CouchDB '%s': %s", dbname, e) + return None + + +fm_db = _connect(FAILURE_MODE_DBNAME) +catalog_db = _connect(CATALOG_DBNAME) + + +def _asset_key(asset_name: str) -> str: + """Normalise an asset name to a class key: strip digits, trim, lowercase ('Pump 1' -> 'pump').""" + return re.sub(r"\d+", "", asset_name or "").strip().lower() # ── Prompt templates ────────────────────────────────────────────────────────── @@ -66,6 +86,13 @@ "For example: \n\n1. foo\n\n2. bar\n\n3. baz" ) +_ASSET2FM_EXTEND_PROMPT = ( + "The asset {asset_name} already has these known failure modes:\n{known}\n\n" + "List ADDITIONAL failure modes for {asset_name} that are NOT already in the list above. " + "Your response should be a numbered list with each failure mode on a new line. " + "Please only list the failure mode name." +) + _RELEVANCY_PROMPT = ( "For the asset {asset_name}, if the failure {failure_mode} occurs, " "can sensor {sensor} help monitor or detect the failure for {asset_name}?\n" @@ -79,7 +106,6 @@ def _parse_numbered_list(text: str) -> list[str]: - """Parse a numbered list response into a plain list of strings.""" items = [] for line in text.splitlines(): m = re.match(r"^\d+[\.\)]\s*(.+)", line.strip()) @@ -89,7 +115,6 @@ def _parse_numbered_list(text: str) -> list[str]: def _parse_relevancy(text: str) -> dict: - """Parse a 3-line relevancy response into {answer, reason, temporal_behavior}.""" lines = [ln for ln in text.strip().splitlines() if ln.strip()] if lines and lines[0].lower().startswith("yes"): answer = "Yes" @@ -106,19 +131,19 @@ def _parse_relevancy(text: str) -> dict: _DEFAULT_MODEL_ID = "watsonx/meta-llama/llama-3-3-70b-instruct" _MAX_RETRIES = 3 +_MODEL_ID = os.environ.get("FMSR_MODEL_ID", _DEFAULT_MODEL_ID) def _build_llm(): from llm import make_backend - model_id = os.environ.get("FMSR_MODEL_ID", _DEFAULT_MODEL_ID) - if model_id.startswith("watsonx/"): + if _MODEL_ID.startswith("watsonx/"): missing = [ v for v in ("WATSONX_APIKEY", "WATSONX_PROJECT_ID") if not os.environ.get(v) ] if missing: raise RuntimeError(f"Missing env vars for WatsonX: {missing}") - elif model_id.startswith("tokenrouter/"): + elif _MODEL_ID.startswith("tokenrouter/"): missing = [ v for v in ("TOKENROUTER_API_KEY", "TOKENROUTER_BASE_URL") @@ -132,29 +157,24 @@ def _build_llm(): ] if missing: raise RuntimeError(f"Missing env vars for LiteLLM: {missing}") - return make_backend(model_id) + return make_backend(_MODEL_ID) try: _llm = _build_llm() _llm_available = True -except Exception as _e: - logger.warning("LLM unavailable (FMSR will use curated data only): %s", _e) +except Exception as _e: # noqa: BLE001 + logger.warning("LLM unavailable (generate_* tools disabled): %s", _e) _llm = None _llm_available = False -# ── LLM call helpers with retry ─────────────────────────────────────────────── - _asset2fm_cache: dict[str, list[str]] = {} def _call_asset2fm(asset_name: str) -> list[str]: - """Query the LLM for failure modes of an asset. Retries up to _MAX_RETRIES times. - Results are cached to avoid redundant LLM calls for the same asset.""" if asset_name in _asset2fm_cache: return _asset2fm_cache[asset_name] - prompt = _ASSET2FM_PROMPT.format(asset_name=asset_name) last_exc: Exception | None = None for _ in range(_MAX_RETRIES): @@ -162,13 +182,27 @@ def _call_asset2fm(asset_name: str) -> list[str]: result = _parse_numbered_list(_llm.generate(prompt)) _asset2fm_cache[asset_name] = result return result - except Exception as exc: + except Exception as exc: # noqa: BLE001 + last_exc = exc + raise last_exc + + +def _call_asset2fm_extend(asset_name: str, known: list[str]) -> list[str]: + """Ask the LLM for ADDITIONAL failure modes given the already-known ones. Retries up to + _MAX_RETRIES. Not cached (depends on the known list).""" + prompt = _ASSET2FM_EXTEND_PROMPT.format( + asset_name=asset_name, known="\n".join(f"- {k}" for k in known) + ) + last_exc: Exception | None = None + for _ in range(_MAX_RETRIES): + try: + return _parse_numbered_list(_llm.generate(prompt)) + except Exception as exc: # noqa: BLE001 last_exc = exc raise last_exc def _call_relevancy(asset_name: str, failure_mode: str, sensor: str) -> dict: - """Query the LLM for FM↔sensor relevancy. Retries up to _MAX_RETRIES times.""" prompt = _RELEVANCY_PROMPT.format( asset_name=asset_name, failure_mode=failure_mode, sensor=sensor ) @@ -176,7 +210,7 @@ def _call_relevancy(asset_name: str, failure_mode: str, sensor: str) -> dict: for _ in range(_MAX_RETRIES): try: return _parse_relevancy(_llm.generate(prompt)) - except Exception as exc: + except Exception as exc: # noqa: BLE001 last_exc = exc raise last_exc @@ -191,6 +225,35 @@ class ErrorResult(BaseModel): class FailureModesResult(BaseModel): asset_name: str failure_modes: List[str] + exhaustive: bool = False # the stored list is not claimed to be complete + source: Optional[str] = None # provenance: ISO / curated / LLM: + + +class GenerateFailureModesResult(BaseModel): + asset_name: str + known: List[str] # pre-existing modes used as context (from DB or provided) + generated: List[str] # newly generated modes NOT already in `known` + failure_modes: List[str] # known + generated (the extended list) + source: str # LLM: + message: str + + +class AddFailureModesResult(BaseModel): + asset_class: str + added: List[str] # newly inserted (not previously present) + total: int # total after the write + exhaustive: bool + source: Optional[str] + message: str + + +class CatalogResult(BaseModel): + kind: str # "sensor" | "failure_mode" + scenario_id: Optional[str] # which scope was served (None = global default) + total: int + items: List[str] + source: Optional[str] + message: str class RelevancyEntry(BaseModel): @@ -219,49 +282,239 @@ class FailureModeSensorMappingResult(BaseModel): mcp = FastMCP( "fmsr", - instructions="Failure mode and sensor reasoning: get failure modes for assets and determine which sensors can detect each failure.", + instructions=( + "Failure mode and sensor reasoning. get_failure_modes READS a class's (possibly partial) " + "failure modes from CouchDB; generate_failure_modes GENERATES them via the LLM when the DB " + "is missing or incomplete (exhaustive=false); add_failure_modes WRITES them back; " + "generate_failure_mode_sensor_mapping GENERATES which sensors can detect each failure. " + "get_sensor_catalog / get_failure_mode_catalog READ the global (class-independent) reference " + "lists from the loaded catalog dataset (scenario-scoped if a scenario_id catalog was loaded); " + "if no catalog collection is loaded they report that none is available." + ), ) @mcp.tool(title="Get Failure Modes") def get_failure_modes(asset_name: str) -> Union[FailureModesResult, ErrorResult]: - """Returns a list of known failure modes for the given asset. - For chillers and AHUs returns a curated list. For other assets queries the LLM.""" - asset_key = re.sub(r"\d+", "", asset_name).strip().lower() - if not asset_key or asset_key == "none": + """READ the known failure modes for an asset from CouchDB (collection 'failure_mode'). The list + may be partial: check `exhaustive` — if false, call generate_failure_modes to supplement. + Does NOT call the LLM. Returns an error if the class is not in the DB.""" + key = _asset_key(asset_name) + if not key or key == "none": return ErrorResult(error="asset_name is required") - - if asset_key in _ASSET_FAILURE_MODES: + if not fm_db: + return ErrorResult(error="CouchDB not connected") + try: + res = fm_db.find({"doctype": "failure_mode", "asset_class": key}, limit=1) + docs = res["docs"] + if not docs: + return ErrorResult( + error=f"no failure_mode record for '{key}' in DB; try generate_failure_modes" + ) + d = docs[0] return FailureModesResult( asset_name=asset_name, - failure_modes=_ASSET_FAILURE_MODES[asset_key], + failure_modes=d.get("failure_modes", []), + exhaustive=d.get("exhaustive", False), + source=d.get("source"), ) + except Exception as exc: # noqa: BLE001 + logger.error("get_failure_modes failed: %s", exc) + return ErrorResult(error=str(exc)) + +def _known_failure_modes(asset_name: str) -> List[str]: + """Current (partial) failure modes stored for the asset's class, [] if none / no DB.""" + if not fm_db: + return [] + try: + r = fm_db.find( + {"doctype": "failure_mode", "asset_class": _asset_key(asset_name)}, limit=1 + ) + return r["docs"][0].get("failure_modes", []) if r["docs"] else [] + except Exception: # noqa: BLE001 + return [] + + +@mcp.tool(title="Generate Failure Modes") +def generate_failure_modes( + asset_name: str, known: Optional[List[str]] = None +) -> Union[GenerateFailureModesResult, ErrorResult]: + """GENERATE failure modes for an asset via the LLM, EXTENDING the known (partial) list. The DB + list is usually not exhaustive, so this asks the LLM for ADDITIONAL modes beyond what is already + known. If `known` is omitted, the current DB list for the class is used as context; if there is + no known list, it generates from scratch. Generated modes already present in `known` are dropped. + Nothing is persisted — call add_failure_modes to save the new ones.""" + if not asset_name: + return ErrorResult(error="asset_name is required") if not _llm_available: - return ErrorResult(error="LLM unavailable and asset not in local database") + return ErrorResult(error="LLM unavailable") + base = known if known is not None else _known_failure_modes(asset_name) + base = [k.strip() for k in base if k and k.strip()] + try: + raw = ( + _call_asset2fm_extend(asset_name, base) + if base + else _call_asset2fm(asset_name) + ) + seen = {k.lower() for k in base} + new: List[str] = [] + for g in raw: + if g and g.lower() not in seen: + seen.add(g.lower()) + new.append(g) + return GenerateFailureModesResult( + asset_name=asset_name, + known=base, + generated=new, + failure_modes=base + new, + source=f"LLM:{_MODEL_ID}", + message=( + f"generated {len(new)} new failure mode(s) extending {len(base)} known " + f"({len(base) + len(new)} total); call add_failure_modes to persist." + ), + ) + except Exception as exc: # noqa: BLE001 + logger.error("generate_failure_modes failed: %s", exc) + return ErrorResult(error=str(exc)) + +@mcp.tool(title="Add Failure Modes") +def add_failure_modes( + asset_class: str, + failure_modes: List[str], + exhaustive: bool = False, + source: Optional[str] = None, +) -> Union[AddFailureModesResult, ErrorResult]: + """WRITE: persist/augment the failure modes for a class in CouchDB. Merges with any existing list + (union, de-duplicated). Use to save generated or curated modes so future get_failure_modes calls + return them. Set exhaustive=true only if the list is now believed complete.""" + key = _asset_key(asset_class) + if not key: + return ErrorResult(error="asset_class is required") + if not failure_modes: + return ErrorResult(error="failure_modes list is required") + if not fm_db: + return ErrorResult(error="CouchDB not connected") + doc_id = f"fm:{key}" try: - result = _call_asset2fm(asset_name) - return FailureModesResult(asset_name=asset_name, failure_modes=result) - except Exception as exc: - logger.error("_call_asset2fm failed: %s", exc) + try: + doc = fm_db.get(doc_id) + except Exception: # noqa: BLE001 + doc = None + existing = set(doc.get("failure_modes", [])) if doc else set() + incoming = {fm.strip() for fm in failure_modes if fm and fm.strip()} + added = sorted(incoming - existing) + merged = sorted(existing | incoming) + new_source = source or (doc.get("source") if doc else None) or "user" + if doc: + doc["failure_modes"] = merged + doc["exhaustive"] = exhaustive + doc["source"] = new_source + fm_db.save(doc) + else: + fm_db.save( + { + "_id": doc_id, + "doctype": "failure_mode", + "asset_class": key, + "failure_modes": merged, + "exhaustive": exhaustive, + "source": new_source, + } + ) + return AddFailureModesResult( + asset_class=key, + added=added, + total=len(merged), + exhaustive=exhaustive, + source=new_source, + message=f"added {len(added)} new failure mode(s) to '{key}' ({len(merged)} total).", + ) + except Exception as exc: # noqa: BLE001 + logger.error("add_failure_modes failed: %s", exc) return ErrorResult(error=str(exc)) -@mcp.tool(title="Get Failure Mode Sensor Mapping") -def get_failure_mode_sensor_mapping( +def _read_catalog( + kind: str, scenario_id: Optional[str] +) -> tuple[list[str], Optional[str], Optional[str]]: + """Return row-based catalog entries for kind from the loaded catalog collection.""" + if not catalog_db: + return [], None, None + + field = "sensor" if kind == "sensor" else "failure_mode" + selector: dict[str, object] = {field: {"$exists": True}} + if scenario_id: + selector["scenario_id"] = scenario_id + + res = catalog_db.find( + selector, + fields=[field, "scenario_id", "source"], + limit=CATALOG_QUERY_LIMIT, + ) + docs = res.get("docs", []) + if not docs and scenario_id: + res = catalog_db.find( + {field: {"$exists": True}}, + fields=[field, "scenario_id", "source"], + limit=CATALOG_QUERY_LIMIT, + ) + docs = res.get("docs", []) + + items = sorted({str(value) for doc in docs if (value := doc.get(field))}) + served = docs[0].get("scenario_id") if docs else None + source = docs[0].get("source") if docs else None + return items, served, source + + +def _catalog_result( + kind: str, scenario_id: Optional[str] +) -> Union[CatalogResult, ErrorResult]: + if not catalog_db: + return ErrorResult(error="CouchDB not connected") + items, served, source = _read_catalog(kind, scenario_id) + if not items: + return ErrorResult(error=f"no catalog information available for kind '{kind}'") + return CatalogResult( + kind=kind, + scenario_id=served, + total=len(items), + items=items, + source=source, + message=f"{len(items)} {kind} items " + f"({'scenario ' + served if served else 'global default'} catalog).", + ) + + +@mcp.tool(title="Get Sensor Catalog") +def get_sensor_catalog( + scenario_id: Optional[str] = None, +) -> Union[CatalogResult, ErrorResult]: + """READ the catalog of all potential sensors / monitoring parameters (class-independent reference + list). If scenario_id is given and a scenario-scoped catalog exists, it is returned; otherwise the + global default. Useful as the candidate-sensor input to generate_failure_mode_sensor_mapping.""" + return _catalog_result("sensor", scenario_id) + + +@mcp.tool(title="Get Failure Mode Catalog") +def get_failure_mode_catalog( + scenario_id: Optional[str] = None, +) -> Union[CatalogResult, ErrorResult]: + """READ the catalog of all potential failure modes (class-independent reference list). Scenario- + scoped if scenario_id matches a registered catalog, else the global default.""" + return _catalog_result("failure_mode", scenario_id) + + +@mcp.tool(title="Generate Failure Mode Sensor Mapping") +def generate_failure_mode_sensor_mapping( asset_name: str, failure_modes: List[str], sensors: List[str], ) -> Union[FailureModeSensorMappingResult, ErrorResult]: - """For each (failure_mode, sensor) pair determines whether the sensor can detect - the failure. Returns a bidirectional mapping (fm→sensors, sensor→fms) plus - the full per-pair relevancy details. - - Note: one LLM call is made per (failure_mode, sensor) pair sequentially. - Keep both lists small (e.g. ≤5 failure modes, ≤10 sensors) to avoid long - runtimes. For a chiller with 7 failure modes and 20+ sensors the call will - take several minutes.""" + """GENERATE, for each (failure_mode, sensor) pair, whether the sensor can detect the failure + (one LLM call per pair). Returns a bidirectional mapping (fm→sensors, sensor→fms) plus per-pair + relevancy details. Keep both lists small (e.g. ≤5 failure modes, ≤10 sensors) to bound runtime.""" if not asset_name: return ErrorResult(error="asset_name is required") if not failure_modes: @@ -274,7 +527,6 @@ def get_failure_mode_sensor_mapping( full_relevancy: List[RelevancyEntry] = [] fm2sensor: Dict[str, List[str]] = {} sensor2fm: Dict[str, List[str]] = {} - try: pairs = [(s, fm) for s in sensors for fm in failure_modes] with ThreadPoolExecutor() as executor: @@ -285,27 +537,26 @@ def get_failure_mode_sensor_mapping( for future in as_completed(futures): s, fm = futures[future] gen = future.result() - entry = RelevancyEntry( - asset_name=asset_name, - failure_mode=fm, - sensor=s, - relevancy_answer=gen["answer"], - relevancy_reason=gen["reason"], - temporal_behavior=gen["temporal_behavior"], + full_relevancy.append( + RelevancyEntry( + asset_name=asset_name, + failure_mode=fm, + sensor=s, + relevancy_answer=gen["answer"], + relevancy_reason=gen["reason"], + temporal_behavior=gen["temporal_behavior"], + ) ) - full_relevancy.append(entry) if "yes" in gen["answer"].lower(): fm2sensor.setdefault(fm, []).append(s) sensor2fm.setdefault(s, []).append(fm) - except Exception as exc: + except Exception as exc: # noqa: BLE001 logger.error("_call_relevancy failed: %s", exc) return ErrorResult(error=str(exc)) return FailureModeSensorMappingResult( metadata=MappingMetadata( - asset_name=asset_name, - failure_modes=failure_modes, - sensors=sensors, + asset_name=asset_name, failure_modes=failure_modes, sensors=sensors ), fm2sensor=fm2sensor, sensor2fm=sensor2fm, @@ -313,6 +564,16 @@ def get_failure_mode_sensor_mapping( ) +@mcp.tool(title="Get Failure Mode Sensor Mapping") +def get_failure_mode_sensor_mapping( + asset_name: str, + failure_modes: List[str], + sensors: List[str], +) -> Union[FailureModeSensorMappingResult, ErrorResult]: + """Backward-compatible alias for generate_failure_mode_sensor_mapping.""" + return generate_failure_mode_sensor_mapping(asset_name, failure_modes, sensors) + + def main(): mcp.run(transport="stdio") diff --git a/src/servers/fmsr/tests/conftest.py b/src/servers/fmsr/tests/conftest.py index b2b10e0db..63d8924f7 100644 --- a/src/servers/fmsr/tests/conftest.py +++ b/src/servers/fmsr/tests/conftest.py @@ -16,6 +16,40 @@ async def call_tool(mcp_instance, tool_name: str, args: dict) -> dict: return json.loads(contents[0].text) +class FakeCouchDB: + def __init__(self, docs=None): + self.docs = {doc["_id"]: dict(doc) for doc in docs or []} + + def find(self, selector, fields=None, limit=None): + docs = [doc for doc in self.docs.values() if self._matches(doc, selector)] + if limit is not None: + docs = docs[:limit] + if fields is not None: + docs = [ + {field: doc[field] for field in fields if field in doc} for doc in docs + ] + return {"docs": docs} + + def get(self, doc_id): + if doc_id not in self.docs: + raise KeyError(doc_id) + return dict(self.docs[doc_id]) + + def save(self, doc): + self.docs[doc["_id"]] = dict(doc) + + @staticmethod + def _matches(doc, selector): + for key, expected in selector.items(): + if isinstance(expected, dict) and "$exists" in expected: + exists = key in doc + if exists != expected["$exists"]: + return False + elif doc.get(key) != expected: + return False + return True + + @pytest.fixture def no_llm(): """Simulate missing WatsonX credentials.""" @@ -23,6 +57,65 @@ def no_llm(): yield +@pytest.fixture +def fake_fm_db(): + db = FakeCouchDB( + [ + { + "_id": "fm:chiller", + "doctype": "failure_mode", + "asset_class": "chiller", + "failure_modes": ["Compressor overheating", "Condenser fouling"], + "exhaustive": False, + "source": "test", + }, + { + "_id": "fm:pump", + "doctype": "failure_mode", + "asset_class": "pump", + "failure_modes": ["seal leakage"], + "exhaustive": False, + "source": "test", + }, + ] + ) + with patch("servers.fmsr.main.fm_db", db): + yield db + + +@pytest.fixture +def empty_fm_db(): + db = FakeCouchDB() + with patch("servers.fmsr.main.fm_db", db): + yield db + + +@pytest.fixture +def fake_catalog_db(): + db = FakeCouchDB( + [ + { + "_id": "catalog:sensor:dp", + "sensor": "differential pressure", + "description": "pressure difference", + }, + { + "_id": "catalog:sensor:temp", + "sensor": "temperature", + "description": "temperature", + }, + { + "_id": "catalog:fm:seal", + "category": "rotating equipment", + "failure_mode": "seal leakage", + "description": "seal loses containment", + }, + ] + ) + with patch("servers.fmsr.main.catalog_db", db): + yield db + + @pytest.fixture def mock_relevancy_chain(): """Patch _call_relevancy so it always returns 'Yes' without calling the LLM.""" @@ -45,3 +138,12 @@ def mock_asset2fm_chain(): with patch("servers.fmsr.main._call_asset2fm", mock): with patch("servers.fmsr.main._llm_available", True): yield mock + + +@pytest.fixture +def mock_asset2fm_extend_chain(): + """Patch _call_asset2fm_extend to return duplicate and new failure modes.""" + mock = MagicMock(return_value=["Seal Leakage", "Bearing Wear"]) + with patch("servers.fmsr.main._call_asset2fm_extend", mock): + with patch("servers.fmsr.main._llm_available", True): + yield mock diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index cece28473..a523a6ac5 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -1,94 +1,169 @@ -"""Tests for FMSR MCP server tools. - -Unit tests use mocked LLM chains; integration tests require live WatsonX -credentials (skipped unless WATSONX_APIKEY is set). -""" +"""Tests for FMSR MCP server tools.""" import pytest -from servers.fmsr.main import mcp -from .conftest import call_tool, requires_watsonx +from servers.fmsr.main import mcp -# --------------------------------------------------------------------------- -# get_failure_modes -# --------------------------------------------------------------------------- +from .conftest import call_tool, requires_watsonx class TestGetFailureModes: @pytest.mark.anyio - async def test_chiller_returns_hardcoded(self): + async def test_reads_failure_modes_from_db(self, fake_fm_db): data = await call_tool(mcp, "get_failure_modes", {"asset_name": "chiller"}) - assert "failure_modes" in data - assert len(data["failure_modes"]) == 7 - assert any("Compressor" in fm for fm in data["failure_modes"]) + + assert data["failure_modes"] == ["Compressor overheating", "Condenser fouling"] + assert data["exhaustive"] is False + assert data["source"] == "test" @pytest.mark.anyio - async def test_chiller_number_stripped(self): - """'Chiller 6' normalises to 'chiller' for the lookup.""" + async def test_chiller_number_stripped(self, fake_fm_db): data = await call_tool(mcp, "get_failure_modes", {"asset_name": "Chiller 6"}) - assert "failure_modes" in data - assert len(data["failure_modes"]) == 7 - @pytest.mark.anyio - async def test_ahu_returns_hardcoded(self): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "ahu"}) - assert "failure_modes" in data - assert len(data["failure_modes"]) == 5 + assert data["failure_modes"] == ["Compressor overheating", "Condenser fouling"] @pytest.mark.anyio - async def test_empty_asset_name_returns_error(self): + async def test_empty_asset_name_returns_error(self, fake_fm_db): data = await call_tool(mcp, "get_failure_modes", {"asset_name": ""}) + assert "error" in data @pytest.mark.anyio - async def test_unknown_asset_no_llm(self, no_llm): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "Pump"}) + async def test_missing_asset_returns_error(self, empty_fm_db): + data = await call_tool(mcp, "get_failure_modes", {"asset_name": "unknown"}) + assert "error" in data + assert "try generate_failure_modes" in data["error"] @pytest.mark.anyio - async def test_unknown_asset_llm_fallback(self, mock_asset2fm_chain): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "Pump"}) - assert "failure_modes" in data + async def test_db_unavailable_returns_error(self, monkeypatch): + monkeypatch.setattr("servers.fmsr.main.fm_db", None) + + data = await call_tool(mcp, "get_failure_modes", {"asset_name": "pump"}) + + assert data == {"error": "CouchDB not connected"} + + +class TestGenerateFailureModes: + @pytest.mark.anyio + async def test_generate_from_scratch(self, empty_fm_db, mock_asset2fm_chain): + data = await call_tool(mcp, "generate_failure_modes", {"asset_name": "Pump"}) + + assert data["known"] == [] + assert data["generated"] == ["Fan Failure", "Belt Wear"] assert data["failure_modes"] == ["Fan Failure", "Belt Wear"] mock_asset2fm_chain.assert_called_once_with("Pump") - @requires_watsonx @pytest.mark.anyio - async def test_unknown_asset_integration(self): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "Boiler"}) - assert "failure_modes" in data - assert len(data["failure_modes"]) > 0 + async def test_generate_extends_known_modes( + self, fake_fm_db, mock_asset2fm_extend_chain + ): + data = await call_tool(mcp, "generate_failure_modes", {"asset_name": "Pump 1"}) + + assert data["known"] == ["seal leakage"] + assert data["generated"] == ["Bearing Wear"] + assert data["failure_modes"] == ["seal leakage", "Bearing Wear"] + mock_asset2fm_extend_chain.assert_called_once_with("Pump 1", ["seal leakage"]) + + @pytest.mark.anyio + async def test_generate_llm_unavailable_returns_error(self, no_llm): + data = await call_tool(mcp, "generate_failure_modes", {"asset_name": "Pump"}) + + assert data == {"error": "LLM unavailable"} + + +class TestAddFailureModes: + @pytest.mark.anyio + async def test_add_failure_modes_merges_existing(self, fake_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + { + "asset_class": "Pump 1", + "failure_modes": ["seal leakage", "Bearing Wear"], + "source": "unit-test", + }, + ) + + assert data["asset_class"] == "pump" + assert data["added"] == ["Bearing Wear"] + assert data["total"] == 2 + assert fake_fm_db.docs["fm:pump"]["failure_modes"] == [ + "Bearing Wear", + "seal leakage", + ] + assert fake_fm_db.docs["fm:pump"]["source"] == "unit-test" + + @pytest.mark.anyio + async def test_add_failure_modes_creates_new_doc(self, empty_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + { + "asset_class": "Gearbox", + "failure_modes": ["Gear tooth wear"], + "exhaustive": True, + }, + ) + + assert data["asset_class"] == "gearbox" + assert data["added"] == ["Gear tooth wear"] + assert data["total"] == 1 + assert empty_fm_db.docs["fm:gearbox"]["exhaustive"] is True + + +class TestCatalogTools: + @pytest.mark.anyio + async def test_get_sensor_catalog_lists_rows(self, fake_catalog_db): + data = await call_tool(mcp, "get_sensor_catalog", {}) + + assert data["kind"] == "sensor" + assert data["total"] == 2 + assert data["items"] == ["differential pressure", "temperature"] + + @pytest.mark.anyio + async def test_get_failure_mode_catalog_lists_rows(self, fake_catalog_db): + data = await call_tool(mcp, "get_failure_mode_catalog", {}) + + assert data["kind"] == "failure_mode" + assert data["total"] == 1 + assert data["items"] == ["seal leakage"] + @pytest.mark.anyio + async def test_catalog_unavailable_returns_error(self, monkeypatch): + monkeypatch.setattr("servers.fmsr.main.catalog_db", None) -# --------------------------------------------------------------------------- -# get_failure_mode_sensor_mapping -# --------------------------------------------------------------------------- + data = await call_tool(mcp, "get_sensor_catalog", {}) + + assert data == {"error": "CouchDB not connected"} _FAILURE_MODES = ["Compressor Overheating", "Condenser Water side fouling"] _SENSORS = ["Chiller 6 Power Input", "Chiller 6 Supply Temperature"] -class TestGetFailureModeSensorMapping: +class TestGenerateFailureModeSensorMapping: @pytest.mark.anyio async def test_returns_expected_keys(self, mock_relevancy_chain): data = await call_tool( mcp, - "get_failure_mode_sensor_mapping", + "generate_failure_mode_sensor_mapping", { "asset_name": "Chiller 6", "failure_modes": _FAILURE_MODES, "sensors": _SENSORS, }, ) + assert "fm2sensor" in data assert "sensor2fm" in data assert "full_relevancy" in data assert data["metadata"]["asset_name"] == "Chiller 6" @pytest.mark.anyio - async def test_full_relevancy_count(self, mock_relevancy_chain): - """2 sensors × 2 failure modes = 4 pairs.""" + async def test_compatibility_alias_returns_expected_keys( + self, mock_relevancy_chain + ): data = await call_tool( mcp, "get_failure_mode_sensor_mapping", @@ -98,51 +173,72 @@ async def test_full_relevancy_count(self, mock_relevancy_chain): "sensors": _SENSORS, }, ) + + assert "fm2sensor" in data + assert "sensor2fm" in data + assert "full_relevancy" in data + + @pytest.mark.anyio + async def test_full_relevancy_count(self, mock_relevancy_chain): + data = await call_tool( + mcp, + "generate_failure_mode_sensor_mapping", + { + "asset_name": "Chiller 6", + "failure_modes": _FAILURE_MODES, + "sensors": _SENSORS, + }, + ) + assert len(data["full_relevancy"]) == 4 @pytest.mark.anyio async def test_empty_failure_modes_returns_error(self, mock_relevancy_chain): data = await call_tool( mcp, - "get_failure_mode_sensor_mapping", + "generate_failure_mode_sensor_mapping", {"asset_name": "Chiller 6", "failure_modes": [], "sensors": _SENSORS}, ) + assert "error" in data @pytest.mark.anyio async def test_empty_sensors_returns_error(self, mock_relevancy_chain): data = await call_tool( mcp, - "get_failure_mode_sensor_mapping", + "generate_failure_mode_sensor_mapping", {"asset_name": "Chiller 6", "failure_modes": _FAILURE_MODES, "sensors": []}, ) + assert "error" in data @pytest.mark.anyio async def test_llm_unavailable_returns_error(self, no_llm): data = await call_tool( mcp, - "get_failure_mode_sensor_mapping", + "generate_failure_mode_sensor_mapping", { "asset_name": "Chiller 6", "failure_modes": _FAILURE_MODES, "sensors": _SENSORS, }, ) - assert "error" in data + + assert data == {"error": "LLM unavailable"} @requires_watsonx @pytest.mark.anyio async def test_integration(self): data = await call_tool( mcp, - "get_failure_mode_sensor_mapping", + "generate_failure_mode_sensor_mapping", { "asset_name": "Chiller 6", "failure_modes": ["Compressor Overheating"], "sensors": ["Chiller 6 Power Input"], }, ) + assert "full_relevancy" in data assert len(data["full_relevancy"]) == 1 assert data["full_relevancy"][0]["relevancy_answer"] in ("Yes", "No", "Unknown") From 945c582472e5fcfa2a637357ce4450cd629ad167 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Wed, 8 Jul 2026 18:03:56 -0400 Subject: [PATCH 07/16] fix: remove duplicate FMSR catalog tools Signed-off-by: Shuxin Lin --- src/servers/fmsr/main.py | 94 ++-------------------------- src/servers/fmsr/tests/conftest.py | 26 -------- src/servers/fmsr/tests/test_tools.py | 26 -------- 3 files changed, 5 insertions(+), 141 deletions(-) diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index d66442a9d..57174a9ad 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -13,7 +13,7 @@ (generate_*) are separate by design. LLM backend is configured via FMSR_MODEL_ID (default: watsonx/meta-llama/llama-3-3-70b-instruct). -CouchDB via COUCHDB_URL / FAILURE_MODE_DBNAME / CATALOG_DBNAME / +CouchDB via COUCHDB_URL / FAILURE_MODE_DBNAME / COUCHDB_USERNAME / COUCHDB_PASSWORD. """ @@ -41,16 +41,14 @@ # ── CouchDB stores ──────────────────────────────────────────────────────────── -# Under AssetOpsBench's loader, database name == collection key, so the FMSR data is two separate -# databases (loaded from the manifest, no seed): 'failure_mode' and 'catalog'. We open one handle -# each — there is no single 'fmsr' database. +# Under AssetOpsBench's loader, database name == collection key. Failure modes +# live in the 'failure_mode' database; generic catalog lookups live in the +# utilities MCP server. COUCHDB_URL = os.environ.get("COUCHDB_URL", "http://localhost:5984") COUCHDB_USERNAME = os.environ.get("COUCHDB_USERNAME", "admin") COUCHDB_PASSWORD = os.environ.get("COUCHDB_PASSWORD", "password") FAILURE_MODE_DBNAME = os.environ.get("FAILURE_MODE_DBNAME", "failure_mode") -CATALOG_DBNAME = os.environ.get("CATALOG_DBNAME", "catalog") -CATALOG_QUERY_LIMIT = 1000 def _connect(dbname): @@ -69,7 +67,6 @@ def _connect(dbname): fm_db = _connect(FAILURE_MODE_DBNAME) -catalog_db = _connect(CATALOG_DBNAME) def _asset_key(asset_name: str) -> str: @@ -247,15 +244,6 @@ class AddFailureModesResult(BaseModel): message: str -class CatalogResult(BaseModel): - kind: str # "sensor" | "failure_mode" - scenario_id: Optional[str] # which scope was served (None = global default) - total: int - items: List[str] - source: Optional[str] - message: str - - class RelevancyEntry(BaseModel): asset_name: str failure_mode: str @@ -287,9 +275,7 @@ class FailureModeSensorMappingResult(BaseModel): "failure modes from CouchDB; generate_failure_modes GENERATES them via the LLM when the DB " "is missing or incomplete (exhaustive=false); add_failure_modes WRITES them back; " "generate_failure_mode_sensor_mapping GENERATES which sensors can detect each failure. " - "get_sensor_catalog / get_failure_mode_catalog READ the global (class-independent) reference " - "lists from the loaded catalog dataset (scenario-scoped if a scenario_id catalog was loaded); " - "if no catalog collection is loaded they report that none is available." + "Use the utilities MCP server for asset, sensor, and failure-mode catalog lookups." ), ) @@ -436,76 +422,6 @@ def add_failure_modes( return ErrorResult(error=str(exc)) -def _read_catalog( - kind: str, scenario_id: Optional[str] -) -> tuple[list[str], Optional[str], Optional[str]]: - """Return row-based catalog entries for kind from the loaded catalog collection.""" - if not catalog_db: - return [], None, None - - field = "sensor" if kind == "sensor" else "failure_mode" - selector: dict[str, object] = {field: {"$exists": True}} - if scenario_id: - selector["scenario_id"] = scenario_id - - res = catalog_db.find( - selector, - fields=[field, "scenario_id", "source"], - limit=CATALOG_QUERY_LIMIT, - ) - docs = res.get("docs", []) - if not docs and scenario_id: - res = catalog_db.find( - {field: {"$exists": True}}, - fields=[field, "scenario_id", "source"], - limit=CATALOG_QUERY_LIMIT, - ) - docs = res.get("docs", []) - - items = sorted({str(value) for doc in docs if (value := doc.get(field))}) - served = docs[0].get("scenario_id") if docs else None - source = docs[0].get("source") if docs else None - return items, served, source - - -def _catalog_result( - kind: str, scenario_id: Optional[str] -) -> Union[CatalogResult, ErrorResult]: - if not catalog_db: - return ErrorResult(error="CouchDB not connected") - items, served, source = _read_catalog(kind, scenario_id) - if not items: - return ErrorResult(error=f"no catalog information available for kind '{kind}'") - return CatalogResult( - kind=kind, - scenario_id=served, - total=len(items), - items=items, - source=source, - message=f"{len(items)} {kind} items " - f"({'scenario ' + served if served else 'global default'} catalog).", - ) - - -@mcp.tool(title="Get Sensor Catalog") -def get_sensor_catalog( - scenario_id: Optional[str] = None, -) -> Union[CatalogResult, ErrorResult]: - """READ the catalog of all potential sensors / monitoring parameters (class-independent reference - list). If scenario_id is given and a scenario-scoped catalog exists, it is returned; otherwise the - global default. Useful as the candidate-sensor input to generate_failure_mode_sensor_mapping.""" - return _catalog_result("sensor", scenario_id) - - -@mcp.tool(title="Get Failure Mode Catalog") -def get_failure_mode_catalog( - scenario_id: Optional[str] = None, -) -> Union[CatalogResult, ErrorResult]: - """READ the catalog of all potential failure modes (class-independent reference list). Scenario- - scoped if scenario_id matches a registered catalog, else the global default.""" - return _catalog_result("failure_mode", scenario_id) - - @mcp.tool(title="Generate Failure Mode Sensor Mapping") def generate_failure_mode_sensor_mapping( asset_name: str, diff --git a/src/servers/fmsr/tests/conftest.py b/src/servers/fmsr/tests/conftest.py index 63d8924f7..03ae75ce1 100644 --- a/src/servers/fmsr/tests/conftest.py +++ b/src/servers/fmsr/tests/conftest.py @@ -90,32 +90,6 @@ def empty_fm_db(): yield db -@pytest.fixture -def fake_catalog_db(): - db = FakeCouchDB( - [ - { - "_id": "catalog:sensor:dp", - "sensor": "differential pressure", - "description": "pressure difference", - }, - { - "_id": "catalog:sensor:temp", - "sensor": "temperature", - "description": "temperature", - }, - { - "_id": "catalog:fm:seal", - "category": "rotating equipment", - "failure_mode": "seal leakage", - "description": "seal loses containment", - }, - ] - ) - with patch("servers.fmsr.main.catalog_db", db): - yield db - - @pytest.fixture def mock_relevancy_chain(): """Patch _call_relevancy so it always returns 'Yes' without calling the LLM.""" diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index a523a6ac5..917790abf 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -112,32 +112,6 @@ async def test_add_failure_modes_creates_new_doc(self, empty_fm_db): assert empty_fm_db.docs["fm:gearbox"]["exhaustive"] is True -class TestCatalogTools: - @pytest.mark.anyio - async def test_get_sensor_catalog_lists_rows(self, fake_catalog_db): - data = await call_tool(mcp, "get_sensor_catalog", {}) - - assert data["kind"] == "sensor" - assert data["total"] == 2 - assert data["items"] == ["differential pressure", "temperature"] - - @pytest.mark.anyio - async def test_get_failure_mode_catalog_lists_rows(self, fake_catalog_db): - data = await call_tool(mcp, "get_failure_mode_catalog", {}) - - assert data["kind"] == "failure_mode" - assert data["total"] == 1 - assert data["items"] == ["seal leakage"] - - @pytest.mark.anyio - async def test_catalog_unavailable_returns_error(self, monkeypatch): - monkeypatch.setattr("servers.fmsr.main.catalog_db", None) - - data = await call_tool(mcp, "get_sensor_catalog", {}) - - assert data == {"error": "CouchDB not connected"} - - _FAILURE_MODES = ["Compressor Overheating", "Condenser Water side fouling"] _SENSORS = ["Chiller 6 Power Input", "Chiller 6 Supply Temperature"] From 0a771ded7b64c9c0aaf51579cb29fb305e229ff3 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 15:21:31 -0400 Subject: [PATCH 08/16] fix: remove FMSR mapping alias Signed-off-by: Shuxin Lin --- docs/mcp-servers.md | 2 +- src/mcphub/workflows.py | 4 ++-- src/servers/fmsr/main.py | 10 ---------- src/servers/fmsr/tests/test_tools.py | 18 ------------------ 4 files changed, 3 insertions(+), 31 deletions(-) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index cf79790ce..bb17095ba 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -65,7 +65,7 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship | Tool | Category | Arguments | Description | | --------------------------------- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get_failure_modes` | read, LLM-use | `asset_name` | Return known failure modes for an asset. Uses a curated YAML list for chillers and AHUs; falls back to the LLM for other types. | -| `get_failure_mode_sensor_mapping` | read, LLM-use | `asset_name`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns bidirectional `fm→sensors` and `sensor→fms` maps plus full per-pair details. | +| `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_name`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns bidirectional `fm→sensors` and `sensor→fms` maps plus full per-pair details. | ## wo — Work Order diff --git a/src/mcphub/workflows.py b/src/mcphub/workflows.py index 7ea6be99c..a1ba2ce6d 100644 --- a/src/mcphub/workflows.py +++ b/src/mcphub/workflows.py @@ -17,7 +17,7 @@ def chiller_triage(tu, asset_id, site="MAIN", raise_work_order=True, priority="2 """Sensors -> failure modes -> mode/sensor mapping -> (optional) work order.""" sensors = tu.run("iot.sensors", {"site_name": site, "asset_id": asset_id}) failure_modes = tu.run("fmsr.get_failure_modes", {"asset_name": asset_id}) - mapping = tu.run("fmsr.get_failure_mode_sensor_mapping", { + mapping = tu.run("fmsr.generate_failure_mode_sensor_mapping", { "asset_name": asset_id, "failure_modes": failure_modes, "sensors": sensors, @@ -63,4 +63,4 @@ def _names(value): return list(value.keys()) if isinstance(value, list): return [v if isinstance(v, str) else v.get("name", str(v)) for v in value] - return [value] \ No newline at end of file + return [value] diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index 57174a9ad..23d8d4ae6 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -480,16 +480,6 @@ def generate_failure_mode_sensor_mapping( ) -@mcp.tool(title="Get Failure Mode Sensor Mapping") -def get_failure_mode_sensor_mapping( - asset_name: str, - failure_modes: List[str], - sensors: List[str], -) -> Union[FailureModeSensorMappingResult, ErrorResult]: - """Backward-compatible alias for generate_failure_mode_sensor_mapping.""" - return generate_failure_mode_sensor_mapping(asset_name, failure_modes, sensors) - - def main(): mcp.run(transport="stdio") diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 917790abf..68b40deed 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -134,24 +134,6 @@ async def test_returns_expected_keys(self, mock_relevancy_chain): assert "full_relevancy" in data assert data["metadata"]["asset_name"] == "Chiller 6" - @pytest.mark.anyio - async def test_compatibility_alias_returns_expected_keys( - self, mock_relevancy_chain - ): - data = await call_tool( - mcp, - "get_failure_mode_sensor_mapping", - { - "asset_name": "Chiller 6", - "failure_modes": _FAILURE_MODES, - "sensors": _SENSORS, - }, - ) - - assert "fm2sensor" in data - assert "sensor2fm" in data - assert "full_relevancy" in data - @pytest.mark.anyio async def test_full_relevancy_count(self, mock_relevancy_chain): data = await call_tool( From 34a618c3ee70127ae3e81bbe266d03e02ea3e190 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 16:18:56 -0400 Subject: [PATCH 09/16] Update FMSR failure mode tools Signed-off-by: Shuxin Lin --- .../shared/fmea/failure_modes_sample.json | 6 +- src/couchdb/tests/test_loader.py | 2 +- src/mcphub/workflows.py | 14 +- src/servers/fmsr/main.py | 378 +++++++++--------- src/servers/fmsr/tests/conftest.py | 37 +- src/servers/fmsr/tests/test_tools.py | 191 ++++++--- 6 files changed, 340 insertions(+), 288 deletions(-) diff --git a/src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json b/src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json index 1bae9df9d..6a29c2211 100644 --- a/src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json +++ b/src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json @@ -1,8 +1,6 @@ [ { - "_id": "fm:sample-pump", - "doctype": "failure_mode", - "asset_class": "sample pump", + "asset_class": "pump", "failure_modes": [ "seal leakage", "impeller wear" @@ -10,4 +8,4 @@ "source": "synthetic sample", "exhaustive": false } -] +] \ No newline at end of file diff --git a/src/couchdb/tests/test_loader.py b/src/couchdb/tests/test_loader.py index 9b3780d93..4cf56e526 100644 --- a/src/couchdb/tests/test_loader.py +++ b/src/couchdb/tests/test_loader.py @@ -53,5 +53,5 @@ def test_failure_mode_collection_parses_shared_json() -> None: assert cfg["format"] == "json" assert cfg["primary_key"] == ["asset_class"] assert len(docs) == 1 - assert docs[0]["asset_class"] == "sample pump" + assert docs[0]["asset_class"] == "pump" assert docs[0]["failure_modes"] == ["seal leakage", "impeller wear"] diff --git a/src/mcphub/workflows.py b/src/mcphub/workflows.py index a1ba2ce6d..8ebae56e3 100644 --- a/src/mcphub/workflows.py +++ b/src/mcphub/workflows.py @@ -10,21 +10,31 @@ ``REGISTERED``. """ +import re + REGISTERED = ["chiller_triage", "sensor_inventory_gap"] +def _asset_class_from_asset_id(asset_id): + key = re.sub(r"\d+", "", asset_id or "") + key = re.sub(r"[_\-]+", " ", key) + return re.sub(r"\s+", " ", key).strip().lower() + + def chiller_triage(tu, asset_id, site="MAIN", raise_work_order=True, priority="2"): """Sensors -> failure modes -> mode/sensor mapping -> (optional) work order.""" + asset_class = _asset_class_from_asset_id(asset_id) sensors = tu.run("iot.sensors", {"site_name": site, "asset_id": asset_id}) - failure_modes = tu.run("fmsr.get_failure_modes", {"asset_name": asset_id}) + failure_modes = tu.run("fmsr.get_failure_modes", {"asset_class": asset_class}) mapping = tu.run("fmsr.generate_failure_mode_sensor_mapping", { - "asset_name": asset_id, + "asset_class": asset_class, "failure_modes": failure_modes, "sensors": sensors, }) out = { "asset_id": asset_id, + "asset_class": asset_class, "site_name": site, "sensors": sensors, "failure_modes": failure_modes, diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index 23d8d4ae6..8e470375d 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -1,16 +1,14 @@ """FMSR (Failure Mode and Sensor Reasoning) MCP Server. Tools: - get_failure_modes – READ the (partial) failure-mode list for an asset from CouchDB - generate_failure_modes – GENERATE failure modes via the LLM (when DB is missing/partial) - add_failure_modes – WRITE: persist/augment a class's failure modes in CouchDB + get_failure_modes – READ the (partial) failure-mode list for an asset class from CouchDB + generate_failure_modes – GENERATE a new/extended failure-mode list via the LLM without writing CouchDB generate_failure_mode_sensor_mapping – GENERATE the bidirectional FM↔sensor relevancy via the LLM -Failure modes live in CouchDB (collection "failure_mode", doctype "failure_mode", -one doc per asset class, loaded from scenario manifests). Coverage is NOT -exhaustive: docs carry `exhaustive: false`, so get_failure_modes returns what is -known and signals when generation is needed. Retrieval (get_*) and generation -(generate_*) are separate by design. +Failure modes live in CouchDB (collection "failure_mode", one doc per asset class, +loaded from scenario manifests). Coverage is NOT exhaustive: docs carry +`exhaustive: false`, so get_failure_modes returns what is known. Retrieval (get_*), +failure-mode generation, and sensor-mapping generation are separate by design. LLM backend is configured via FMSR_MODEL_ID (default: watsonx/meta-llama/llama-3-3-70b-instruct). CouchDB via COUCHDB_URL / FAILURE_MODE_DBNAME / @@ -69,45 +67,70 @@ def _connect(dbname): fm_db = _connect(FAILURE_MODE_DBNAME) -def _asset_key(asset_name: str) -> str: - """Normalise an asset name to a class key: strip digits, trim, lowercase ('Pump 1' -> 'pump').""" - return re.sub(r"\d+", "", asset_name or "").strip().lower() +def _asset_class_key(asset_class: str) -> str: + """Normalise an asset class to the CouchDB key format ('Hydraulic_Pump' -> 'hydraulic pump').""" + key = re.sub(r"\d+", "", asset_class or "") + key = re.sub(r"[_\-]+", " ", key) + return re.sub(r"\s+", " ", key).strip().lower() -# ── Prompt templates ────────────────────────────────────────────────────────── +def _known_asset_classes(limit: int = 10) -> List[str]: + """Return known asset classes from the failure_mode collection for error guidance.""" + if not fm_db: + return [] + try: + res = fm_db.find({}, fields=["asset_class"], limit=limit) + except Exception: # noqa: BLE001 + return [] + classes = [ + doc.get("asset_class") + for doc in res.get("docs", []) + if isinstance(doc.get("asset_class"), str) and doc.get("asset_class") + ] + return sorted(dict.fromkeys(classes)) + + +def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult: + message = ( + f"no failure_mode record for asset_class '{normalized}' in DB. " + f"Input was normalized from {original!r}; check that asset_class matches a stored class." + ) + known = _known_asset_classes() + if known: + message += f" Available asset_class values include: {', '.join(known)}." + return ErrorResult(error=message) -_ASSET2FM_PROMPT = ( - "What are different failure modes for asset {asset_name}?\n" - "Your response should be a numbered list with each failure mode on a new line. " - "Please only list the failure mode name.\n" - "For example: \n\n1. foo\n\n2. bar\n\n3. baz" -) -_ASSET2FM_EXTEND_PROMPT = ( - "The asset {asset_name} already has these known failure modes:\n{known}\n\n" - "List ADDITIONAL failure modes for {asset_name} that are NOT already in the list above. " - "Your response should be a numbered list with each failure mode on a new line. " - "Please only list the failure mode name." -) +# ── Prompt templates ────────────────────────────────────────────────────────── _RELEVANCY_PROMPT = ( - "For the asset {asset_name}, if the failure {failure_mode} occurs, " - "can sensor {sensor} help monitor or detect the failure for {asset_name}?\n" - "Provide the answer in the first line and reason in the second line. " - "If the answer is Yes, provide the temporal behaviour of the sensor " - "when the failure occurs in the third line." + "For asset class {asset_class}, if the failure {failure_mode} occurs, " + "can sensor {sensor} help monitor or detect the failure for assets of class {asset_class}?\n" + "Provide the answer in the first line and reason in the second line." ) +_FAILURE_MODE_PROMPT = ( + "List up to {max_modes} common failure modes for asset class {asset_class}.\n" + "Return only failure mode names, one per line." +) -# ── Output parsers ──────────────────────────────────────────────────────────── +_FAILURE_MODE_EXTEND_PROMPT = ( + "Asset class {asset_class} already has these known failure modes:\n{known}\n\n" + "List up to {max_modes} additional failure modes for asset class {asset_class} " + "that are not already in the known list.\n" + "Return only failure mode names, one per line." +) -def _parse_numbered_list(text: str) -> list[str]: - items = [] +def _parse_failure_mode_list(text: str) -> List[str]: + items: List[str] = [] for line in text.splitlines(): - m = re.match(r"^\d+[\.\)]\s*(.+)", line.strip()) - if m: - items.append(m.group(1).strip()) + item = line.strip() + if not item: + continue + item = re.sub(r"^\s*(?:[-*•]|\d+[\.\)])\s*", "", item).strip() + if item: + items.append(item) return items @@ -120,8 +143,7 @@ def _parse_relevancy(text: str) -> dict: else: answer = "Unknown" reason = lines[1] if len(lines) >= 2 else "Unknown" - temporal = lines[2] if (answer == "Yes" and len(lines) >= 3) else "Unknown" - return {"answer": answer, "reason": reason, "temporal_behavior": temporal} + return {"answer": answer, "reason": reason} # ── LLM backend (lazy init; graceful degradation if creds are absent) ───────── @@ -166,47 +188,35 @@ def _build_llm(): _llm_available = False -_asset2fm_cache: dict[str, list[str]] = {} - - -def _call_asset2fm(asset_name: str) -> list[str]: - if asset_name in _asset2fm_cache: - return _asset2fm_cache[asset_name] - prompt = _ASSET2FM_PROMPT.format(asset_name=asset_name) - last_exc: Exception | None = None - for _ in range(_MAX_RETRIES): - try: - result = _parse_numbered_list(_llm.generate(prompt)) - _asset2fm_cache[asset_name] = result - return result - except Exception as exc: # noqa: BLE001 - last_exc = exc - raise last_exc - - -def _call_asset2fm_extend(asset_name: str, known: list[str]) -> list[str]: - """Ask the LLM for ADDITIONAL failure modes given the already-known ones. Retries up to - _MAX_RETRIES. Not cached (depends on the known list).""" - prompt = _ASSET2FM_EXTEND_PROMPT.format( - asset_name=asset_name, known="\n".join(f"- {k}" for k in known) +def _call_relevancy(asset_class: str, failure_mode: str, sensor: str) -> dict: + prompt = _RELEVANCY_PROMPT.format( + asset_class=asset_class, failure_mode=failure_mode, sensor=sensor ) last_exc: Exception | None = None for _ in range(_MAX_RETRIES): try: - return _parse_numbered_list(_llm.generate(prompt)) + return _parse_relevancy(_llm.generate(prompt)) except Exception as exc: # noqa: BLE001 last_exc = exc raise last_exc -def _call_relevancy(asset_name: str, failure_mode: str, sensor: str) -> dict: - prompt = _RELEVANCY_PROMPT.format( - asset_name=asset_name, failure_mode=failure_mode, sensor=sensor +def _call_failure_mode_generation( + asset_class: str, known: List[str], max_modes: int +) -> List[str]: + prompt = ( + _FAILURE_MODE_EXTEND_PROMPT.format( + asset_class=asset_class, + known="\n".join(f"- {mode}" for mode in known), + max_modes=max_modes, + ) + if known + else _FAILURE_MODE_PROMPT.format(asset_class=asset_class, max_modes=max_modes) ) last_exc: Exception | None = None for _ in range(_MAX_RETRIES): try: - return _parse_relevancy(_llm.generate(prompt)) + return _parse_failure_mode_list(_llm.generate(prompt)) except Exception as exc: # noqa: BLE001 last_exc = exc raise last_exc @@ -220,41 +230,31 @@ class ErrorResult(BaseModel): class FailureModesResult(BaseModel): - asset_name: str + asset_class: str failure_modes: List[str] exhaustive: bool = False # the stored list is not claimed to be complete source: Optional[str] = None # provenance: ISO / curated / LLM: class GenerateFailureModesResult(BaseModel): - asset_name: str - known: List[str] # pre-existing modes used as context (from DB or provided) - generated: List[str] # newly generated modes NOT already in `known` - failure_modes: List[str] # known + generated (the extended list) - source: str # LLM: - message: str - - -class AddFailureModesResult(BaseModel): asset_class: str - added: List[str] # newly inserted (not previously present) - total: int # total after the write - exhaustive: bool - source: Optional[str] + known: List[str] + generated: List[str] + failure_modes: List[str] + source: str message: str class RelevancyEntry(BaseModel): - asset_name: str + asset_class: str failure_mode: str sensor: str relevancy_answer: str relevancy_reason: str - temporal_behavior: str class MappingMetadata(BaseModel): - asset_name: str + asset_class: str failure_modes: List[str] sensors: List[str] @@ -271,9 +271,8 @@ class FailureModeSensorMappingResult(BaseModel): mcp = FastMCP( "fmsr", instructions=( - "Failure mode and sensor reasoning. get_failure_modes READS a class's (possibly partial) " - "failure modes from CouchDB; generate_failure_modes GENERATES them via the LLM when the DB " - "is missing or incomplete (exhaustive=false); add_failure_modes WRITES them back; " + "Failure mode and sensor reasoning. get_failure_modes READS an asset class's (possibly partial) " + "failure modes; generate_failure_modes GENERATES a new or extended list without writing the DB; " "generate_failure_mode_sensor_mapping GENERATES which sensors can detect each failure. " "Use the utilities MCP server for asset, sensor, and failure-mode catalog lookups." ), @@ -281,25 +280,33 @@ class FailureModeSensorMappingResult(BaseModel): @mcp.tool(title="Get Failure Modes") -def get_failure_modes(asset_name: str) -> Union[FailureModesResult, ErrorResult]: - """READ the known failure modes for an asset from CouchDB (collection 'failure_mode'). The list - may be partial: check `exhaustive` — if false, call generate_failure_modes to supplement. - Does NOT call the LLM. Returns an error if the class is not in the DB.""" - key = _asset_key(asset_name) +def get_failure_modes(asset_class: str) -> Union[FailureModesResult, ErrorResult]: + """READ the known failure modes for an asset class. + + Args: + asset_class: Asset class to look up, such as "pump". Case, whitespace, + underscores, and hyphens are normalized before querying. + """ + raw_asset_class = asset_class + key = _asset_class_key(asset_class) if not key or key == "none": - return ErrorResult(error="asset_name is required") + return ErrorResult(error="asset_class is required") if not fm_db: return ErrorResult(error="CouchDB not connected") try: - res = fm_db.find({"doctype": "failure_mode", "asset_class": key}, limit=1) - docs = res["docs"] - if not docs: - return ErrorResult( - error=f"no failure_mode record for '{key}' in DB; try generate_failure_modes" - ) - d = docs[0] + try: + d = fm_db.get(f"fm:{key}") + except Exception: # noqa: BLE001 + d = None + if d is None: + res = fm_db.find({"asset_class": key}, limit=1) + docs = res["docs"] + if docs: + d = docs[0] + if d is None: + return _missing_asset_class_error(raw_asset_class, key) return FailureModesResult( - asset_name=asset_name, + asset_class=d.get("asset_class", key), failure_modes=d.get("failure_modes", []), exhaustive=d.get("exhaustive", False), source=d.get("source"), @@ -309,55 +316,84 @@ def get_failure_modes(asset_name: str) -> Union[FailureModesResult, ErrorResult] return ErrorResult(error=str(exc)) -def _known_failure_modes(asset_name: str) -> List[str]: - """Current (partial) failure modes stored for the asset's class, [] if none / no DB.""" +def _known_failure_modes(asset_class: str) -> List[str]: + """Return stored failure modes for an asset class, or [] if none are available.""" if not fm_db: return [] + key = _asset_class_key(asset_class) try: - r = fm_db.find( - {"doctype": "failure_mode", "asset_class": _asset_key(asset_name)}, limit=1 - ) - return r["docs"][0].get("failure_modes", []) if r["docs"] else [] + try: + d = fm_db.get(f"fm:{key}") + except Exception: # noqa: BLE001 + d = None + if d is None: + res = fm_db.find({"asset_class": key}, limit=1) + docs = res["docs"] + if docs: + d = docs[0] + if d is None: + return [] + return [ + mode.strip() + for mode in d.get("failure_modes", []) + if isinstance(mode, str) and mode.strip() + ] except Exception: # noqa: BLE001 return [] @mcp.tool(title="Generate Failure Modes") def generate_failure_modes( - asset_name: str, known: Optional[List[str]] = None + asset_class: str, + known: Optional[List[str]] = None, + max_modes: int = 10, ) -> Union[GenerateFailureModesResult, ErrorResult]: - """GENERATE failure modes for an asset via the LLM, EXTENDING the known (partial) list. The DB - list is usually not exhaustive, so this asks the LLM for ADDITIONAL modes beyond what is already - known. If `known` is omitted, the current DB list for the class is used as context; if there is - no known list, it generates from scratch. Generated modes already present in `known` are dropped. - Nothing is persisted — call add_failure_modes to save the new ones.""" - if not asset_name: - return ErrorResult(error="asset_name is required") + """GENERATE a new or extended failure-mode list for an asset class. + + This tool does not write to CouchDB. If `known` is omitted, the current + CouchDB failure modes for `asset_class` are used as context when available. + If no stored modes exist, the LLM generates a new list from scratch. + + Args: + asset_class: Asset class to reason about, such as "pump". Case, + whitespace, underscores, and hyphens are normalized before prompting + the LLM. + known: Optional known failure modes to extend. When provided, this list + is used instead of reading the current CouchDB list. + max_modes: Maximum number of new failure modes to request from the LLM. + """ + key = _asset_class_key(asset_class) + if not key or key == "none": + return ErrorResult(error="asset_class is required") + if max_modes <= 0: + return ErrorResult(error="max_modes must be greater than 0") if not _llm_available: return ErrorResult(error="LLM unavailable") - base = known if known is not None else _known_failure_modes(asset_name) - base = [k.strip() for k in base if k and k.strip()] + + base = known if known is not None else _known_failure_modes(key) + base = [mode.strip() for mode in base if mode and mode.strip()] + try: - raw = ( - _call_asset2fm_extend(asset_name, base) - if base - else _call_asset2fm(asset_name) - ) - seen = {k.lower() for k in base} - new: List[str] = [] - for g in raw: - if g and g.lower() not in seen: - seen.add(g.lower()) - new.append(g) + raw = _call_failure_mode_generation(key, base, max_modes) + seen = {mode.lower() for mode in base} + generated: List[str] = [] + for mode in raw: + candidate = mode.strip() + normalized = candidate.lower() + if candidate and normalized not in seen: + seen.add(normalized) + generated.append(candidate) + if len(generated) > max_modes: + generated = generated[:max_modes] return GenerateFailureModesResult( - asset_name=asset_name, + asset_class=key, known=base, - generated=new, - failure_modes=base + new, + generated=generated, + failure_modes=base + generated, source=f"LLM:{_MODEL_ID}", message=( - f"generated {len(new)} new failure mode(s) extending {len(base)} known " - f"({len(base) + len(new)} total); call add_failure_modes to persist." + f"generated {len(generated)} new failure mode(s) for asset_class '{key}' " + f"using {len(base)} known mode(s) as context; nothing was persisted." ), ) except Exception as exc: # noqa: BLE001 @@ -365,74 +401,25 @@ def generate_failure_modes( return ErrorResult(error=str(exc)) -@mcp.tool(title="Add Failure Modes") -def add_failure_modes( - asset_class: str, - failure_modes: List[str], - exhaustive: bool = False, - source: Optional[str] = None, -) -> Union[AddFailureModesResult, ErrorResult]: - """WRITE: persist/augment the failure modes for a class in CouchDB. Merges with any existing list - (union, de-duplicated). Use to save generated or curated modes so future get_failure_modes calls - return them. Set exhaustive=true only if the list is now believed complete.""" - key = _asset_key(asset_class) - if not key: - return ErrorResult(error="asset_class is required") - if not failure_modes: - return ErrorResult(error="failure_modes list is required") - if not fm_db: - return ErrorResult(error="CouchDB not connected") - doc_id = f"fm:{key}" - try: - try: - doc = fm_db.get(doc_id) - except Exception: # noqa: BLE001 - doc = None - existing = set(doc.get("failure_modes", [])) if doc else set() - incoming = {fm.strip() for fm in failure_modes if fm and fm.strip()} - added = sorted(incoming - existing) - merged = sorted(existing | incoming) - new_source = source or (doc.get("source") if doc else None) or "user" - if doc: - doc["failure_modes"] = merged - doc["exhaustive"] = exhaustive - doc["source"] = new_source - fm_db.save(doc) - else: - fm_db.save( - { - "_id": doc_id, - "doctype": "failure_mode", - "asset_class": key, - "failure_modes": merged, - "exhaustive": exhaustive, - "source": new_source, - } - ) - return AddFailureModesResult( - asset_class=key, - added=added, - total=len(merged), - exhaustive=exhaustive, - source=new_source, - message=f"added {len(added)} new failure mode(s) to '{key}' ({len(merged)} total).", - ) - except Exception as exc: # noqa: BLE001 - logger.error("add_failure_modes failed: %s", exc) - return ErrorResult(error=str(exc)) - - @mcp.tool(title="Generate Failure Mode Sensor Mapping") def generate_failure_mode_sensor_mapping( - asset_name: str, + asset_class: str, failure_modes: List[str], sensors: List[str], ) -> Union[FailureModeSensorMappingResult, ErrorResult]: """GENERATE, for each (failure_mode, sensor) pair, whether the sensor can detect the failure (one LLM call per pair). Returns a bidirectional mapping (fm→sensors, sensor→fms) plus per-pair - relevancy details. Keep both lists small (e.g. ≤5 failure modes, ≤10 sensors) to bound runtime.""" - if not asset_name: - return ErrorResult(error="asset_name is required") + relevancy details. Keep both lists small (e.g. ≤5 failure modes, ≤10 sensors) to bound runtime. + + Args: + asset_class: Asset class to reason about, such as "pump". Case, whitespace, + underscores, and hyphens are normalized before prompting the LLM. + failure_modes: Failure modes for the asset class. + sensors: Sensor names to evaluate for detection relevance. + """ + key = _asset_class_key(asset_class) + if not key or key == "none": + return ErrorResult(error="asset_class is required") if not failure_modes: return ErrorResult(error="failure_modes list is required") if not sensors: @@ -447,7 +434,7 @@ def generate_failure_mode_sensor_mapping( pairs = [(s, fm) for s in sensors for fm in failure_modes] with ThreadPoolExecutor() as executor: futures = { - executor.submit(_call_relevancy, asset_name, fm, s): (s, fm) + executor.submit(_call_relevancy, key, fm, s): (s, fm) for s, fm in pairs } for future in as_completed(futures): @@ -455,12 +442,11 @@ def generate_failure_mode_sensor_mapping( gen = future.result() full_relevancy.append( RelevancyEntry( - asset_name=asset_name, + asset_class=key, failure_mode=fm, sensor=s, relevancy_answer=gen["answer"], relevancy_reason=gen["reason"], - temporal_behavior=gen["temporal_behavior"], ) ) if "yes" in gen["answer"].lower(): @@ -472,7 +458,7 @@ def generate_failure_mode_sensor_mapping( return FailureModeSensorMappingResult( metadata=MappingMetadata( - asset_name=asset_name, failure_modes=failure_modes, sensors=sensors + asset_class=key, failure_modes=failure_modes, sensors=sensors ), fm2sensor=fm2sensor, sensor2fm=sensor2fm, diff --git a/src/servers/fmsr/tests/conftest.py b/src/servers/fmsr/tests/conftest.py index 03ae75ce1..2f2fafccf 100644 --- a/src/servers/fmsr/tests/conftest.py +++ b/src/servers/fmsr/tests/conftest.py @@ -61,21 +61,12 @@ def no_llm(): def fake_fm_db(): db = FakeCouchDB( [ - { - "_id": "fm:chiller", - "doctype": "failure_mode", - "asset_class": "chiller", - "failure_modes": ["Compressor overheating", "Condenser fouling"], - "exhaustive": False, - "source": "test", - }, { "_id": "fm:pump", - "doctype": "failure_mode", "asset_class": "pump", - "failure_modes": ["seal leakage"], + "failure_modes": ["seal leakage", "impeller wear"], "exhaustive": False, - "source": "test", + "source": "synthetic sample", }, ] ) @@ -97,7 +88,6 @@ def mock_relevancy_chain(): return_value={ "answer": "Yes", "reason": "Relevant sensor", - "temporal_behavior": "Increases", } ) with patch("servers.fmsr.main._call_relevancy", mock): @@ -106,18 +96,15 @@ def mock_relevancy_chain(): @pytest.fixture -def mock_asset2fm_chain(): - """Patch _call_asset2fm to return a fixed failure mode list.""" - mock = MagicMock(return_value=["Fan Failure", "Belt Wear"]) - with patch("servers.fmsr.main._call_asset2fm", mock): - with patch("servers.fmsr.main._llm_available", True): - yield mock - - -@pytest.fixture -def mock_asset2fm_extend_chain(): - """Patch _call_asset2fm_extend to return duplicate and new failure modes.""" - mock = MagicMock(return_value=["Seal Leakage", "Bearing Wear"]) - with patch("servers.fmsr.main._call_asset2fm_extend", mock): +def mock_failure_mode_generation(): + """Patch failure-mode generation so tests do not call the LLM.""" + mock = MagicMock( + return_value=[ + "bearing wear", + "seal leakage", + "motor overheating", + ] + ) + with patch("servers.fmsr.main._call_failure_mode_generation", mock): with patch("servers.fmsr.main._llm_available", True): yield mock diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 68b40deed..23ba311ad 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -10,106 +10,162 @@ class TestGetFailureModes: @pytest.mark.anyio async def test_reads_failure_modes_from_db(self, fake_fm_db): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "chiller"}) + data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"}) - assert data["failure_modes"] == ["Compressor overheating", "Condenser fouling"] + assert data["asset_class"] == "pump" + assert data["failure_modes"] == ["seal leakage", "impeller wear"] assert data["exhaustive"] is False - assert data["source"] == "test" + assert data["source"] == "synthetic sample" @pytest.mark.anyio - async def test_chiller_number_stripped(self, fake_fm_db): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "Chiller 6"}) + async def test_asset_class_case_normalized(self, fake_fm_db): + data = await call_tool(mcp, "get_failure_modes", {"asset_class": "PUMP"}) - assert data["failure_modes"] == ["Compressor overheating", "Condenser fouling"] + assert data["asset_class"] == "pump" + assert data["failure_modes"] == ["seal leakage", "impeller wear"] @pytest.mark.anyio - async def test_empty_asset_name_returns_error(self, fake_fm_db): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": ""}) + async def test_asset_class_spacing_normalized(self, fake_fm_db): + data = await call_tool(mcp, "get_failure_modes", {"asset_class": " PUMP "}) - assert "error" in data + assert data["asset_class"] == "pump" + assert data["failure_modes"] == ["seal leakage", "impeller wear"] @pytest.mark.anyio - async def test_missing_asset_returns_error(self, empty_fm_db): - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "unknown"}) + async def test_empty_asset_class_returns_error(self, fake_fm_db): + data = await call_tool(mcp, "get_failure_modes", {"asset_class": ""}) + + assert data == {"error": "asset_class is required"} + + @pytest.mark.anyio + async def test_missing_asset_class_returns_guidance(self, fake_fm_db): + data = await call_tool(mcp, "get_failure_modes", {"asset_class": "bad-pump-1"}) assert "error" in data - assert "try generate_failure_modes" in data["error"] + assert "no failure_mode record for asset_class 'bad pump'" in data["error"] + assert "Input was normalized from 'bad-pump-1'" in data["error"] + assert "Available asset_class values include: pump" in data["error"] @pytest.mark.anyio async def test_db_unavailable_returns_error(self, monkeypatch): monkeypatch.setattr("servers.fmsr.main.fm_db", None) - data = await call_tool(mcp, "get_failure_modes", {"asset_name": "pump"}) + data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"}) assert data == {"error": "CouchDB not connected"} class TestGenerateFailureModes: @pytest.mark.anyio - async def test_generate_from_scratch(self, empty_fm_db, mock_asset2fm_chain): - data = await call_tool(mcp, "generate_failure_modes", {"asset_name": "Pump"}) + async def test_extends_failure_modes_from_db( + self, fake_fm_db, mock_failure_mode_generation + ): + data = await call_tool( + mcp, + "generate_failure_modes", + {"asset_class": "Pump", "max_modes": 5}, + ) + + assert data["asset_class"] == "pump" + assert data["known"] == ["seal leakage", "impeller wear"] + assert data["generated"] == ["bearing wear", "motor overheating"] + assert data["failure_modes"] == [ + "seal leakage", + "impeller wear", + "bearing wear", + "motor overheating", + ] + assert data["source"].startswith("LLM:") + assert "nothing was persisted" in data["message"] + mock_failure_mode_generation.assert_called_once_with( + "pump", ["seal leakage", "impeller wear"], 5 + ) + + @pytest.mark.anyio + async def test_generates_from_scratch_for_missing_db_record( + self, empty_fm_db, mock_failure_mode_generation + ): + data = await call_tool( + mcp, + "generate_failure_modes", + {"asset_class": "compressor", "max_modes": 3}, + ) + assert data["asset_class"] == "compressor" assert data["known"] == [] - assert data["generated"] == ["Fan Failure", "Belt Wear"] - assert data["failure_modes"] == ["Fan Failure", "Belt Wear"] - mock_asset2fm_chain.assert_called_once_with("Pump") + assert data["generated"] == [ + "bearing wear", + "seal leakage", + "motor overheating", + ] + mock_failure_mode_generation.assert_called_once_with("compressor", [], 3) @pytest.mark.anyio - async def test_generate_extends_known_modes( - self, fake_fm_db, mock_asset2fm_extend_chain + async def test_known_argument_overrides_db( + self, fake_fm_db, mock_failure_mode_generation ): - data = await call_tool(mcp, "generate_failure_modes", {"asset_name": "Pump 1"}) + data = await call_tool( + mcp, + "generate_failure_modes", + { + "asset_class": "Pump-1", + "known": ["bearing wear"], + "max_modes": 10, + }, + ) - assert data["known"] == ["seal leakage"] - assert data["generated"] == ["Bearing Wear"] - assert data["failure_modes"] == ["seal leakage", "Bearing Wear"] - mock_asset2fm_extend_chain.assert_called_once_with("Pump 1", ["seal leakage"]) + assert data["asset_class"] == "pump" + assert data["known"] == ["bearing wear"] + assert data["generated"] == ["seal leakage", "motor overheating"] + mock_failure_mode_generation.assert_called_once_with( + "pump", ["bearing wear"], 10 + ) @pytest.mark.anyio - async def test_generate_llm_unavailable_returns_error(self, no_llm): - data = await call_tool(mcp, "generate_failure_modes", {"asset_name": "Pump"}) + async def test_empty_asset_class_returns_error(self, mock_failure_mode_generation): + data = await call_tool( + mcp, + "generate_failure_modes", + {"asset_class": "", "max_modes": 3}, + ) - assert data == {"error": "LLM unavailable"} + assert data == {"error": "asset_class is required"} + @pytest.mark.anyio + async def test_invalid_max_modes_returns_error(self, mock_failure_mode_generation): + data = await call_tool( + mcp, + "generate_failure_modes", + {"asset_class": "pump", "max_modes": 0}, + ) + + assert data == {"error": "max_modes must be greater than 0"} -class TestAddFailureModes: @pytest.mark.anyio - async def test_add_failure_modes_merges_existing(self, fake_fm_db): + async def test_llm_unavailable_returns_error(self, no_llm): data = await call_tool( mcp, - "add_failure_modes", - { - "asset_class": "Pump 1", - "failure_modes": ["seal leakage", "Bearing Wear"], - "source": "unit-test", - }, + "generate_failure_modes", + {"asset_class": "pump", "max_modes": 3}, ) - assert data["asset_class"] == "pump" - assert data["added"] == ["Bearing Wear"] - assert data["total"] == 2 - assert fake_fm_db.docs["fm:pump"]["failure_modes"] == [ - "Bearing Wear", - "seal leakage", - ] - assert fake_fm_db.docs["fm:pump"]["source"] == "unit-test" + assert data == {"error": "LLM unavailable"} + @requires_watsonx @pytest.mark.anyio - async def test_add_failure_modes_creates_new_doc(self, empty_fm_db): + async def test_integration(self): data = await call_tool( mcp, - "add_failure_modes", + "generate_failure_modes", { - "asset_class": "Gearbox", - "failure_modes": ["Gear tooth wear"], - "exhaustive": True, + "asset_class": "pump", + "known": ["seal leakage"], + "max_modes": 2, }, ) - assert data["asset_class"] == "gearbox" - assert data["added"] == ["Gear tooth wear"] - assert data["total"] == 1 - assert empty_fm_db.docs["fm:gearbox"]["exhaustive"] is True + assert "generated" in data + assert len(data["generated"]) <= 2 _FAILURE_MODES = ["Compressor Overheating", "Condenser Water side fouling"] @@ -123,7 +179,7 @@ async def test_returns_expected_keys(self, mock_relevancy_chain): mcp, "generate_failure_mode_sensor_mapping", { - "asset_name": "Chiller 6", + "asset_class": "chiller", "failure_modes": _FAILURE_MODES, "sensors": _SENSORS, }, @@ -132,7 +188,8 @@ async def test_returns_expected_keys(self, mock_relevancy_chain): assert "fm2sensor" in data assert "sensor2fm" in data assert "full_relevancy" in data - assert data["metadata"]["asset_name"] == "Chiller 6" + assert data["metadata"]["asset_class"] == "chiller" + assert data["full_relevancy"][0]["asset_class"] == "chiller" @pytest.mark.anyio async def test_full_relevancy_count(self, mock_relevancy_chain): @@ -140,7 +197,7 @@ async def test_full_relevancy_count(self, mock_relevancy_chain): mcp, "generate_failure_mode_sensor_mapping", { - "asset_name": "Chiller 6", + "asset_class": "Chiller", "failure_modes": _FAILURE_MODES, "sensors": _SENSORS, }, @@ -153,17 +210,31 @@ async def test_empty_failure_modes_returns_error(self, mock_relevancy_chain): data = await call_tool( mcp, "generate_failure_mode_sensor_mapping", - {"asset_name": "Chiller 6", "failure_modes": [], "sensors": _SENSORS}, + {"asset_class": "chiller", "failure_modes": [], "sensors": _SENSORS}, ) assert "error" in data + @pytest.mark.anyio + async def test_empty_asset_class_returns_error(self, mock_relevancy_chain): + data = await call_tool( + mcp, + "generate_failure_mode_sensor_mapping", + { + "asset_class": "", + "failure_modes": _FAILURE_MODES, + "sensors": _SENSORS, + }, + ) + + assert data == {"error": "asset_class is required"} + @pytest.mark.anyio async def test_empty_sensors_returns_error(self, mock_relevancy_chain): data = await call_tool( mcp, "generate_failure_mode_sensor_mapping", - {"asset_name": "Chiller 6", "failure_modes": _FAILURE_MODES, "sensors": []}, + {"asset_class": "chiller", "failure_modes": _FAILURE_MODES, "sensors": []}, ) assert "error" in data @@ -174,7 +245,7 @@ async def test_llm_unavailable_returns_error(self, no_llm): mcp, "generate_failure_mode_sensor_mapping", { - "asset_name": "Chiller 6", + "asset_class": "chiller", "failure_modes": _FAILURE_MODES, "sensors": _SENSORS, }, @@ -189,7 +260,7 @@ async def test_integration(self): mcp, "generate_failure_mode_sensor_mapping", { - "asset_name": "Chiller 6", + "asset_class": "chiller", "failure_modes": ["Compressor Overheating"], "sensors": ["Chiller 6 Power Input"], }, From 26c072b97869c2e34c0fdf189e28afb571927f59 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 16:19:15 -0400 Subject: [PATCH 10/16] Document FMSR asset class workflow Signed-off-by: Shuxin Lin --- README.md | 4 +- docs/mcp-servers.md | 54 ++++++++++++++++++-- docs/opencode-agent.md | 113 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 162 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a333da839..8f41029b5 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ AssetOpsBench is a **unified framework for developing, orchestrating, and evalua | MCP Servers | Important tools | |---|---| | **IoT** | `get_sites`, `get_history`, `get_assets`, `get_sensors` | -| **FMSR** | `get_sensors`, `get_failure_modes`, `get_failure_sensor_mapping` | +| **FMSR** | `get_failure_modes`, `generate_failure_modes`, `generate_failure_mode_sensor_mapping` | | **TSFM** | `forecasting`, `timeseries_anomaly_detection` | | **WO** | `get_work_order_distribution`, `predict_next_work_order`, ... | | **Vibration** | `compute_fft_spectrum`, `compute_envelope_spectrum`, ... | @@ -109,7 +109,7 @@ The `src/` directory contains MCP servers and a plan-execute runner built on the | Domain | Example Task | |---|---| | **IoT** | "List all sensors of Chiller 6 in MAIN site" | -| **FMSR** | "Identify failure modes detected by Chiller 6 Supply Temperature" | +| **FMSR** | "List known failure modes for asset class pump" | | **TSFM** | "Forecast Chiller 9 Condenser Water Flow for the week of 2020-04-27" | | **WO** | "Generate a work order for Chiller 6 anomaly detection" | diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index bb17095ba..4b9066eda 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -59,13 +59,59 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship ## fmsr — Failure Mode and Sensor Relations **Path:** `src/servers/fmsr/main.py` -**Requires:** `WATSONX_APIKEY`, `WATSONX_PROJECT_ID`, `WATSONX_URL` for unknown assets; curated lists for `chiller` and `ahu` work without credentials. -**Failure-mode data:** `src/servers/fmsr/failure_modes.yaml` (edit to add/change asset entries) +**Requires:** LLM credentials for `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads CouchDB. +**Failure-mode data:** `src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json` loaded into the `failure_mode` CouchDB collection. + +The active FMSR surface is intentionally small: + +- `get_failure_modes` reads known data only. It does not call an LLM and does + not generate or persist new failure modes. +- `generate_failure_modes` calls an LLM to create a new failure-mode list or + extend known modes for an `asset_class`. It may read the existing CouchDB list + as context, but it never writes to CouchDB. +- `generate_failure_mode_sensor_mapping` calls an LLM to score supplied + `(failure_mode, sensor)` pairs. It does not write to CouchDB. +- `add_failure_modes` is not exposed for now. + +Use `asset_class` for FMSR calls, not `asset_name`. `asset_class` is the +equipment class stored in the FMEA data, such as `pump`, rather than a specific +asset id such as `Pump 3`. The server normalizes case, repeated whitespace, +underscores, hyphens, and digits before lookup or prompting, so values like +`Pump_3`, `pump-3`, and ` PUMP ` normalize to `pump`. If no matching +failure-mode record is found, the tool returns an error that includes the +normalized class and a short list of available `asset_class` values from the +database. + +Current failure-mode JSON rows use this shape: + +```json +{ + "asset_class": "pump", + "failure_modes": ["seal leakage", "impeller wear"], + "source": "synthetic sample", + "exhaustive": false +} +``` + +`generate_failure_modes` and `generate_failure_mode_sensor_mapping` use the FMSR +server's own model setting, `FMSR_MODEL_ID`, not the calling agent's +`--model-id`. The default is `watsonx/meta-llama/llama-3-3-70b-instruct`. To +share one model value with an agent run, set a shell variable and pass it to +both: + +```bash +MODEL_ID=tokenrouter/MiniMax-M3 + +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ + "Call generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor']." +``` | Tool | Category | Arguments | Description | | --------------------------------- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `get_failure_modes` | read, LLM-use | `asset_name` | Return known failure modes for an asset. Uses a curated YAML list for chillers and AHUs; falls back to the LLM for other types. | -| `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_name`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns bidirectional `fm→sensors` and `sensor→fms` maps plus full per-pair details. | +| `get_failure_modes` | read | `asset_class` | Return known failure modes for an asset class from CouchDB. Returns `asset_class`, `failure_modes`, `exhaustive`, and `source`. | +| `generate_failure_modes` | read, LLM-use | `asset_class`, `known?`, `max_modes?` | Generate a new or extended failure-mode list. If `known` is omitted, current CouchDB modes for the class are used as context when available. Returns `known`, `generated`, and combined `failure_modes`; does not edit CouchDB. | +| `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_class`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns `metadata`, `fm2sensor`, `sensor2fm`, and `full_relevancy`; per-pair details include `relevancy_answer` and `relevancy_reason`, not `temporal_behavior`. | ## wo — Work Order diff --git a/docs/opencode-agent.md b/docs/opencode-agent.md index 7d5d98f1d..8f3e07786 100644 --- a/docs/opencode-agent.md +++ b/docs/opencode-agent.md @@ -199,6 +199,94 @@ The custom provider route is important because OpenCode's built-in `openai/*` provider validates model names against its own model registry. Router-hosted models such as `MiniMax-M3` must be registered explicitly. +### FMSR tool model + +`--model-id` controls the OpenCode agent model: the model that decides which +tools to call and writes the final answer. It does not automatically control +LLM calls made inside MCP servers. + +The FMSR tools `generate_failure_modes` and +`generate_failure_mode_sensor_mapping` make their own LLM calls, configured by +`FMSR_MODEL_ID` in `src/servers/fmsr/main.py`. To use the same router model for +both the OpenCode agent and the FMSR generation tools, set one shell variable +and pass it to both places: + +```bash +MODEL_ID=tokenrouter/MiniMax-M3 + +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ + "Call generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor']." +``` + +The shared value must be valid for both systems. For example, +`tokenrouter/MiniMax-M3` can be used by both OpenCode routing and FMSR. An +OpenCode-only model such as `opencode/big-pickle` is not a valid FMSR backend. + +### FMSR smoke tests + +`get_failure_modes` reads CouchDB and does not make an LLM call. It takes +`asset_class`, not `asset_name`; the current shared sample data includes +`asset_class: pump`. + +```bash +uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ + "Use the FMSR get_failure_modes tool for asset_class pump. Return only the asset_class, failure_modes, source, and exhaustive fields." +``` + +To check normalization, use mixed case, extra whitespace, digits, underscores, +or hyphens: + +```bash +uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ + "Use the FMSR get_failure_modes tool for asset_class ' Pump-1 '. Return the normalized asset_class and failure_modes." +``` + +To check missing-class guidance: + +```bash +uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ + "Use the FMSR get_failure_modes tool for asset_class compressor and report the tool error exactly." +``` + +`generate_failure_modes` creates a new or extended list but does not edit +CouchDB. When `known` is omitted, it uses the current DB list for `asset_class` +as context if one exists: + +```bash +MODEL_ID=tokenrouter/MiniMax-M3 + +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ + "Use generate_failure_modes for asset_class pump with max_modes 5. Return known, generated, failure_modes, and message." +``` + +To generate from caller-provided context instead of the DB: + +```bash +MODEL_ID=tokenrouter/MiniMax-M3 + +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ + "Use generate_failure_modes for asset_class compressor with known ['bearing wear'] and max_modes 4. Return only generated and failure_modes." +``` + +`generate_failure_mode_sensor_mapping` calls the FMSR backend model once per +failure-mode/sensor pair. Set `FMSR_MODEL_ID` when you want the mapping tool to +use the same TokenRouter model as the OpenCode agent: + +```bash +MODEL_ID=tokenrouter/MiniMax-M3 + +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ + "Use generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage', 'impeller wear'] and sensors ['Discharge pressure sensor', 'Motor current sensor', 'Vibration sensor', 'Flow sensor']. Return fm2sensor, sensor2fm, and full_relevancy." +``` + +The mapping response no longer includes `temporal_behavior`; per-pair entries +contain `asset_class`, `failure_mode`, `sensor`, `relevancy_answer`, and +`relevancy_reason`. + --- ## Permissions and web access @@ -383,7 +471,26 @@ uv run opencode-agent --run-id opencode-smoke --scenario-id smoke \ --model-id tokenrouter/MiniMax-M3 \ "What sites are available?" -# 5. benchmark-suite dry run +# 5. FMSR get_failure_modes smoke test +uv run opencode-agent --show-trajectory \ + --model-id tokenrouter/MiniMax-M3 \ + "Use the FMSR get_failure_modes tool for asset_class pump. Return only the failure_modes list." + +# 6. FMSR generate_failure_modes smoke test +MODEL_ID=tokenrouter/MiniMax-M3 +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --show-trajectory \ + --model-id "$MODEL_ID" \ + "Use generate_failure_modes for asset_class pump with max_modes 3. Return generated and message." + +# 7. FMSR mapping smoke test +MODEL_ID=tokenrouter/MiniMax-M3 +FMSR_MODEL_ID="$MODEL_ID" \ +uv run opencode-agent --show-trajectory \ + --model-id "$MODEL_ID" \ + "Use generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor', 'Vibration sensor']." + +# 8. benchmark-suite dry run uv run python -m benchmark.scenario_suite_runner \ --scenario-ids benchmarks/scenario_suite/scenarios.txt \ --scenario-root /path/to/scenarios_data \ @@ -391,7 +498,7 @@ uv run python -m benchmark.scenario_suite_runner \ --model-id tokenrouter/MiniMax-M3 \ --dry-run -# 6. benchmark-suite CLI workspace dry run +# 9. benchmark-suite CLI workspace dry run uv run python -m benchmark.scenario_suite_runner \ --scenario-ids benchmarks/scenario_suite/scenarios.txt \ --scenario-root /path/to/scenarios_data \ @@ -435,4 +542,4 @@ uv run python -m benchmark.scenario_suite_runner \ documentation. No MCP servers are modified. The runner generates OpenCode MCP/provider config -at runtime. \ No newline at end of file +at runtime. From 0debdefa092d4985418be6b223843929d90e528d Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 16:22:30 -0400 Subject: [PATCH 11/16] Remove manual known modes override Signed-off-by: Shuxin Lin --- docs/mcp-servers.md | 9 +++++---- docs/opencode-agent.md | 11 ++++++----- src/servers/fmsr/main.py | 12 +++++------- src/servers/fmsr/tests/test_tools.py | 22 ---------------------- 4 files changed, 16 insertions(+), 38 deletions(-) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 4b9066eda..1a99ce447 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -59,7 +59,7 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship ## fmsr — Failure Mode and Sensor Relations **Path:** `src/servers/fmsr/main.py` -**Requires:** LLM credentials for `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads CouchDB. +**Requires:** LLM credentials for `generate_failure_modes` and `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads CouchDB. **Failure-mode data:** `src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json` loaded into the `failure_mode` CouchDB collection. The active FMSR surface is intentionally small: @@ -67,8 +67,9 @@ The active FMSR surface is intentionally small: - `get_failure_modes` reads known data only. It does not call an LLM and does not generate or persist new failure modes. - `generate_failure_modes` calls an LLM to create a new failure-mode list or - extend known modes for an `asset_class`. It may read the existing CouchDB list - as context, but it never writes to CouchDB. + extend existing modes for an `asset_class`. If the class exists in CouchDB, + the stored list is used as context; otherwise, the LLM generates a new list + from scratch. It never writes to CouchDB. - `generate_failure_mode_sensor_mapping` calls an LLM to score supplied `(failure_mode, sensor)` pairs. It does not write to CouchDB. - `add_failure_modes` is not exposed for now. @@ -110,7 +111,7 @@ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ | Tool | Category | Arguments | Description | | --------------------------------- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get_failure_modes` | read | `asset_class` | Return known failure modes for an asset class from CouchDB. Returns `asset_class`, `failure_modes`, `exhaustive`, and `source`. | -| `generate_failure_modes` | read, LLM-use | `asset_class`, `known?`, `max_modes?` | Generate a new or extended failure-mode list. If `known` is omitted, current CouchDB modes for the class are used as context when available. Returns `known`, `generated`, and combined `failure_modes`; does not edit CouchDB. | +| `generate_failure_modes` | read, LLM-use | `asset_class`, `max_modes?` | Generate a new or extended failure-mode list. Current CouchDB modes for the class are used as context when available. Returns DB context, `generated`, and combined `failure_modes`; does not edit CouchDB. | | `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_class`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns `metadata`, `fm2sensor`, `sensor2fm`, and `full_relevancy`; per-pair details include `relevancy_answer` and `relevancy_reason`, not `temporal_behavior`. | ## wo — Work Order diff --git a/docs/opencode-agent.md b/docs/opencode-agent.md index 8f3e07786..25ec2d070 100644 --- a/docs/opencode-agent.md +++ b/docs/opencode-agent.md @@ -250,25 +250,26 @@ uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ ``` `generate_failure_modes` creates a new or extended list but does not edit -CouchDB. When `known` is omitted, it uses the current DB list for `asset_class` -as context if one exists: +CouchDB. If the normalized `asset_class` exists in CouchDB, the stored list is +used as context; otherwise, the tool generates a new list from scratch: ```bash MODEL_ID=tokenrouter/MiniMax-M3 FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_modes for asset_class pump with max_modes 5. Return known, generated, failure_modes, and message." + "Use generate_failure_modes for asset_class pump with max_modes 5. Return generated, failure_modes, and message." ``` -To generate from caller-provided context instead of the DB: +To generate from scratch for a class that is not currently in the failure-mode +DB: ```bash MODEL_ID=tokenrouter/MiniMax-M3 FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_modes for asset_class compressor with known ['bearing wear'] and max_modes 4. Return only generated and failure_modes." + "Use generate_failure_modes for asset_class compressor with max_modes 4. Return generated, failure_modes, and message." ``` `generate_failure_mode_sensor_mapping` calls the FMSR backend model once per diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index 8e470375d..ac450af8f 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -345,21 +345,19 @@ def _known_failure_modes(asset_class: str) -> List[str]: @mcp.tool(title="Generate Failure Modes") def generate_failure_modes( asset_class: str, - known: Optional[List[str]] = None, max_modes: int = 10, ) -> Union[GenerateFailureModesResult, ErrorResult]: """GENERATE a new or extended failure-mode list for an asset class. - This tool does not write to CouchDB. If `known` is omitted, the current - CouchDB failure modes for `asset_class` are used as context when available. - If no stored modes exist, the LLM generates a new list from scratch. + This tool does not write to CouchDB. If the normalized `asset_class` exists + in CouchDB, the current stored failure modes are used as context and the LLM + generates additional modes. If no stored modes exist, the LLM generates a + new list from scratch. Args: asset_class: Asset class to reason about, such as "pump". Case, whitespace, underscores, and hyphens are normalized before prompting the LLM. - known: Optional known failure modes to extend. When provided, this list - is used instead of reading the current CouchDB list. max_modes: Maximum number of new failure modes to request from the LLM. """ key = _asset_class_key(asset_class) @@ -370,7 +368,7 @@ def generate_failure_modes( if not _llm_available: return ErrorResult(error="LLM unavailable") - base = known if known is not None else _known_failure_modes(key) + base = _known_failure_modes(key) base = [mode.strip() for mode in base if mode and mode.strip()] try: diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 23ba311ad..9cd0d2acc 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -100,27 +100,6 @@ async def test_generates_from_scratch_for_missing_db_record( ] mock_failure_mode_generation.assert_called_once_with("compressor", [], 3) - @pytest.mark.anyio - async def test_known_argument_overrides_db( - self, fake_fm_db, mock_failure_mode_generation - ): - data = await call_tool( - mcp, - "generate_failure_modes", - { - "asset_class": "Pump-1", - "known": ["bearing wear"], - "max_modes": 10, - }, - ) - - assert data["asset_class"] == "pump" - assert data["known"] == ["bearing wear"] - assert data["generated"] == ["seal leakage", "motor overheating"] - mock_failure_mode_generation.assert_called_once_with( - "pump", ["bearing wear"], 10 - ) - @pytest.mark.anyio async def test_empty_asset_class_returns_error(self, mock_failure_mode_generation): data = await call_tool( @@ -159,7 +138,6 @@ async def test_integration(self): "generate_failure_modes", { "asset_class": "pump", - "known": ["seal leakage"], "max_modes": 2, }, ) From 564c2cff05b2494e4b9a9ccb733a2c49f6541008 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 16:51:35 -0400 Subject: [PATCH 12/16] Add FMSR failure mode database writes Signed-off-by: Shuxin Lin --- README.md | 2 +- docs/mcp-servers.md | 18 ++- docs/opencode-agent.md | 34 +++-- src/mcphub/tests/test_workflows.py | 38 +++++ src/mcphub/workflows.py | 10 +- src/servers/fmsr/main.py | 214 ++++++++++++++++++++------- src/servers/fmsr/tests/conftest.py | 30 +++- src/servers/fmsr/tests/test_tools.py | 141 +++++++++++++++++- 8 files changed, 408 insertions(+), 79 deletions(-) create mode 100644 src/mcphub/tests/test_workflows.py diff --git a/README.md b/README.md index 8f41029b5..6ae9bd2e1 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ AssetOpsBench is a **unified framework for developing, orchestrating, and evalua | MCP Servers | Important tools | |---|---| | **IoT** | `get_sites`, `get_history`, `get_assets`, `get_sensors` | -| **FMSR** | `get_failure_modes`, `generate_failure_modes`, `generate_failure_mode_sensor_mapping` | +| **FMSR** | `get_failure_modes`, `generate_failure_modes`, `add_failure_modes`, `generate_failure_mode_sensor_mapping` | | **TSFM** | `forecasting`, `timeseries_anomaly_detection` | | **WO** | `get_work_order_distribution`, `predict_next_work_order`, ... | | **Vibration** | `compute_fft_spectrum`, `compute_envelope_spectrum`, ... | diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 1a99ce447..7ab2f1b29 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -59,20 +59,21 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship ## fmsr — Failure Mode and Sensor Relations **Path:** `src/servers/fmsr/main.py` -**Requires:** LLM credentials for `generate_failure_modes` and `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads CouchDB. -**Failure-mode data:** `src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json` loaded into the `failure_mode` CouchDB collection. +**Requires:** LLM credentials for `generate_failure_modes` and `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads the database. +**Failure-mode data:** `src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json` loaded into the `failure_mode` database collection. The active FMSR surface is intentionally small: - `get_failure_modes` reads known data only. It does not call an LLM and does not generate or persist new failure modes. - `generate_failure_modes` calls an LLM to create a new failure-mode list or - extend existing modes for an `asset_class`. If the class exists in CouchDB, + extend existing modes for an `asset_class`. If the class exists in the database, the stored list is used as context; otherwise, the LLM generates a new list - from scratch. It never writes to CouchDB. + from scratch. It never writes to the database. +- `add_failure_modes` writes curated or generated modes to the database. It + merges with the current list and de-duplicates modes case-insensitively. - `generate_failure_mode_sensor_mapping` calls an LLM to score supplied - `(failure_mode, sensor)` pairs. It does not write to CouchDB. -- `add_failure_modes` is not exposed for now. + `(failure_mode, sensor)` pairs. It does not write to the database. Use `asset_class` for FMSR calls, not `asset_name`. `asset_class` is the equipment class stored in the FMEA data, such as `pump`, rather than a specific @@ -110,8 +111,9 @@ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ | Tool | Category | Arguments | Description | | --------------------------------- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `get_failure_modes` | read | `asset_class` | Return known failure modes for an asset class from CouchDB. Returns `asset_class`, `failure_modes`, `exhaustive`, and `source`. | -| `generate_failure_modes` | read, LLM-use | `asset_class`, `max_modes?` | Generate a new or extended failure-mode list. Current CouchDB modes for the class are used as context when available. Returns DB context, `generated`, and combined `failure_modes`; does not edit CouchDB. | +| `get_failure_modes` | read | `asset_class` | Return known failure modes for an asset class from the database. Returns `asset_class`, `failure_modes`, `exhaustive`, and `source`. | +| `generate_failure_modes` | read, LLM-use | `asset_class`, `max_modes?` | Generate a new or extended failure-mode list. Current database modes for the class are used as context when available. Returns `known`, `generated`, and combined `failure_modes`; does not edit the database. | +| `add_failure_modes` | write | `asset_class`, `failure_modes`, `exhaustive?`, `source?` | Persist/augment failure modes for an asset class. Merges with existing modes, returns `added`, `failure_modes`, `total`, `exhaustive`, and `source`. If `exhaustive` is omitted, existing records preserve their stored value and new records default to false. | | `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_class`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns `metadata`, `fm2sensor`, `sensor2fm`, and `full_relevancy`; per-pair details include `relevancy_answer` and `relevancy_reason`, not `temporal_behavior`. | ## wo — Work Order diff --git a/docs/opencode-agent.md b/docs/opencode-agent.md index 25ec2d070..840bfcd49 100644 --- a/docs/opencode-agent.md +++ b/docs/opencode-agent.md @@ -225,7 +225,7 @@ OpenCode-only model such as `opencode/big-pickle` is not a valid FMSR backend. ### FMSR smoke tests -`get_failure_modes` reads CouchDB and does not make an LLM call. It takes +`get_failure_modes` reads the database and does not make an LLM call. It takes `asset_class`, not `asset_name`; the current shared sample data includes `asset_class: pump`. @@ -249,27 +249,34 @@ uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ "Use the FMSR get_failure_modes tool for asset_class compressor and report the tool error exactly." ``` -`generate_failure_modes` creates a new or extended list but does not edit -CouchDB. If the normalized `asset_class` exists in CouchDB, the stored list is -used as context; otherwise, the tool generates a new list from scratch: +`generate_failure_modes` creates a new or extended list but does not edit the +database. If the normalized `asset_class` exists in the database, the stored +list is used as context; otherwise, the tool generates a new list from scratch: ```bash MODEL_ID=tokenrouter/MiniMax-M3 FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_modes for asset_class pump with max_modes 5. Return generated, failure_modes, and message." + "Use generate_failure_modes for asset_class pump with max_modes 5. Return known, generated, failure_modes, and message." ``` To generate from scratch for a class that is not currently in the failure-mode -DB: +database: ```bash MODEL_ID=tokenrouter/MiniMax-M3 FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_modes for asset_class compressor with max_modes 4. Return generated, failure_modes, and message." + "Use generate_failure_modes for asset_class compressor with max_modes 4. Return known, generated, failure_modes, and message." +``` + +`add_failure_modes` writes to the database and does not call an LLM: + +```bash +uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ + "Use add_failure_modes for asset_class pump with failure_modes ['bearing wear', 'seal leakage'], exhaustive false, and source 'manual smoke test'. Return asset_class, added, failure_modes, total, source, and message." ``` `generate_failure_mode_sensor_mapping` calls the FMSR backend model once per @@ -482,16 +489,21 @@ MODEL_ID=tokenrouter/MiniMax-M3 FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --show-trajectory \ --model-id "$MODEL_ID" \ - "Use generate_failure_modes for asset_class pump with max_modes 3. Return generated and message." + "Use generate_failure_modes for asset_class pump with max_modes 3. Return known, generated, and message." + +# 7. FMSR add_failure_modes smoke test +uv run opencode-agent --show-trajectory \ + --model-id tokenrouter/MiniMax-M3 \ + "Use add_failure_modes for asset_class pump with failure_modes ['bearing wear'], exhaustive false, and source 'manual smoke test'. Return added, total, source, and message." -# 7. FMSR mapping smoke test +# 8. FMSR mapping smoke test MODEL_ID=tokenrouter/MiniMax-M3 FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --show-trajectory \ --model-id "$MODEL_ID" \ "Use generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor', 'Vibration sensor']." -# 8. benchmark-suite dry run +# 9. benchmark-suite dry run uv run python -m benchmark.scenario_suite_runner \ --scenario-ids benchmarks/scenario_suite/scenarios.txt \ --scenario-root /path/to/scenarios_data \ @@ -499,7 +511,7 @@ uv run python -m benchmark.scenario_suite_runner \ --model-id tokenrouter/MiniMax-M3 \ --dry-run -# 9. benchmark-suite CLI workspace dry run +# 10. benchmark-suite CLI workspace dry run uv run python -m benchmark.scenario_suite_runner \ --scenario-ids benchmarks/scenario_suite/scenarios.txt \ --scenario-root /path/to/scenarios_data \ diff --git a/src/mcphub/tests/test_workflows.py b/src/mcphub/tests/test_workflows.py new file mode 100644 index 000000000..e9a4c08c1 --- /dev/null +++ b/src/mcphub/tests/test_workflows.py @@ -0,0 +1,38 @@ +from mcphub.workflows import chiller_triage + + +class FakeToolUniverse: + def __init__(self): + self.calls = [] + + def run(self, name, arguments): + self.calls.append((name, arguments)) + if name == "iot.sensors": + return {"sensors": ["Pressure sensor", "Vibration sensor"]} + if name == "fmsr.get_failure_modes": + return { + "asset_class": "pump", + "failure_modes": ["seal leakage", "impeller wear"], + } + if name == "fmsr.generate_failure_mode_sensor_mapping": + return {"metadata": arguments} + raise AssertionError(f"unexpected tool call: {name}") + + +def test_chiller_triage_passes_lists_to_mapping_tool(): + tu = FakeToolUniverse() + + result = chiller_triage(tu, asset_id="Pump-1", raise_work_order=False) + + mapping_call = [ + arguments + for name, arguments in tu.calls + if name == "fmsr.generate_failure_mode_sensor_mapping" + ][0] + assert mapping_call == { + "asset_class": "pump", + "failure_modes": ["seal leakage", "impeller wear"], + "sensors": ["Pressure sensor", "Vibration sensor"], + } + assert result["failure_modes_result"]["asset_class"] == "pump" + assert result["failure_modes"] == ["seal leakage", "impeller wear"] diff --git a/src/mcphub/workflows.py b/src/mcphub/workflows.py index 8ebae56e3..141f372d2 100644 --- a/src/mcphub/workflows.py +++ b/src/mcphub/workflows.py @@ -24,8 +24,13 @@ def _asset_class_from_asset_id(asset_id): def chiller_triage(tu, asset_id, site="MAIN", raise_work_order=True, priority="2"): """Sensors -> failure modes -> mode/sensor mapping -> (optional) work order.""" asset_class = _asset_class_from_asset_id(asset_id) - sensors = tu.run("iot.sensors", {"site_name": site, "asset_id": asset_id}) - failure_modes = tu.run("fmsr.get_failure_modes", {"asset_class": asset_class}) + sensors = _names(tu.run("iot.sensors", {"site_name": site, "asset_id": asset_id})) + failure_modes_result = tu.run("fmsr.get_failure_modes", {"asset_class": asset_class}) + failure_modes = ( + failure_modes_result.get("failure_modes", []) + if isinstance(failure_modes_result, dict) + else failure_modes_result + ) mapping = tu.run("fmsr.generate_failure_mode_sensor_mapping", { "asset_class": asset_class, "failure_modes": failure_modes, @@ -37,6 +42,7 @@ def chiller_triage(tu, asset_id, site="MAIN", raise_work_order=True, priority="2 "asset_class": asset_class, "site_name": site, "sensors": sensors, + "failure_modes_result": failure_modes_result, "failure_modes": failure_modes, "failure_mode_sensor_mapping": mapping, } diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index ac450af8f..2146ed66e 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -1,17 +1,18 @@ """FMSR (Failure Mode and Sensor Reasoning) MCP Server. Tools: - get_failure_modes – READ the (partial) failure-mode list for an asset class from CouchDB - generate_failure_modes – GENERATE a new/extended failure-mode list via the LLM without writing CouchDB + get_failure_modes – READ the (partial) failure-mode list for an asset class from the database + generate_failure_modes – GENERATE a new/extended failure-mode list via the LLM without writing the database + add_failure_modes – WRITE: persist/augment an asset class's failure modes in the database generate_failure_mode_sensor_mapping – GENERATE the bidirectional FM↔sensor relevancy via the LLM -Failure modes live in CouchDB (collection "failure_mode", one doc per asset class, +Failure modes live in the database (collection "failure_mode", one doc per asset class, loaded from scenario manifests). Coverage is NOT exhaustive: docs carry `exhaustive: false`, so get_failure_modes returns what is known. Retrieval (get_*), -failure-mode generation, and sensor-mapping generation are separate by design. +failure-mode generation, database writes, and sensor-mapping generation are separate by design. LLM backend is configured via FMSR_MODEL_ID (default: watsonx/meta-llama/llama-3-3-70b-instruct). -CouchDB via COUCHDB_URL / FAILURE_MODE_DBNAME / +Database via COUCHDB_URL / FAILURE_MODE_DBNAME / COUCHDB_USERNAME / COUCHDB_PASSWORD. """ @@ -25,6 +26,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed import couchdb3 +from couchdb3.exceptions import NotFoundError from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP from pydantic import BaseModel @@ -38,7 +40,7 @@ logger = logging.getLogger("fmsr-mcp-server") -# ── CouchDB stores ──────────────────────────────────────────────────────────── +# ── Database stores ─────────────────────────────────────────────────────────── # Under AssetOpsBench's loader, database name == collection key. Failure modes # live in the 'failure_mode' database; generic catalog lookups live in the # utilities MCP server. @@ -57,10 +59,10 @@ def _connect(dbname): user=COUCHDB_USERNAME, password=COUCHDB_PASSWORD, ) - logger.info("Connected to CouchDB: %s", dbname) + logger.info("Connected to database: %s", dbname) return h except Exception as e: # noqa: BLE001 - logger.error("Failed to connect to CouchDB '%s': %s", dbname, e) + logger.error("Failed to connect to database '%s': %s", dbname, e) return None @@ -68,7 +70,7 @@ def _connect(dbname): def _asset_class_key(asset_class: str) -> str: - """Normalise an asset class to the CouchDB key format ('Hydraulic_Pump' -> 'hydraulic pump').""" + """Normalise an asset class to the database key format ('Hydraulic_Pump' -> 'hydraulic pump').""" key = re.sub(r"\d+", "", asset_class or "") key = re.sub(r"[_\-]+", " ", key) return re.sub(r"\s+", " ", key).strip().lower() @@ -92,7 +94,7 @@ def _known_asset_classes(limit: int = 10) -> List[str]: def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult: message = ( - f"no failure_mode record for asset_class '{normalized}' in DB. " + f"no failure_mode record for asset_class '{normalized}' in database. " f"Input was normalized from {original!r}; check that asset_class matches a stored class." ) known = _known_asset_classes() @@ -101,6 +103,16 @@ def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult: return ErrorResult(error=message) +def _is_not_found_error(exc: Exception) -> bool: + if isinstance(exc, (KeyError, NotFoundError)): + return True + response = getattr(exc, "response", None) + status_code = getattr(response, "status_code", None) or getattr( + exc, "status_code", None + ) + return status_code == 404 + + # ── Prompt templates ────────────────────────────────────────────────────────── _RELEVANCY_PROMPT = ( @@ -115,9 +127,9 @@ def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult: ) _FAILURE_MODE_EXTEND_PROMPT = ( - "Asset class {asset_class} already has these known failure modes:\n{known}\n\n" + "Asset class {asset_class} already has these stored failure modes:\n{stored_modes}\n\n" "List up to {max_modes} additional failure modes for asset class {asset_class} " - "that are not already in the known list.\n" + "that are not already in the stored list.\n" "Return only failure mode names, one per line." ) @@ -207,7 +219,7 @@ def _call_failure_mode_generation( prompt = ( _FAILURE_MODE_EXTEND_PROMPT.format( asset_class=asset_class, - known="\n".join(f"- {mode}" for mode in known), + stored_modes="\n".join(f"- {mode}" for mode in known), max_modes=max_modes, ) if known @@ -245,6 +257,16 @@ class GenerateFailureModesResult(BaseModel): message: str +class AddFailureModesResult(BaseModel): + asset_class: str + added: List[str] + failure_modes: List[str] + total: int + exhaustive: bool + source: Optional[str] = None + message: str + + class RelevancyEntry(BaseModel): asset_class: str failure_mode: str @@ -272,7 +294,8 @@ class FailureModeSensorMappingResult(BaseModel): "fmsr", instructions=( "Failure mode and sensor reasoning. get_failure_modes READS an asset class's (possibly partial) " - "failure modes; generate_failure_modes GENERATES a new or extended list without writing the DB; " + "failure modes; generate_failure_modes GENERATES a new or extended list without writing the database; " + "add_failure_modes WRITES curated or generated modes to the database; " "generate_failure_mode_sensor_mapping GENERATES which sensors can detect each failure. " "Use the utilities MCP server for asset, sensor, and failure-mode catalog lookups." ), @@ -285,24 +308,14 @@ def get_failure_modes(asset_class: str) -> Union[FailureModesResult, ErrorResult Args: asset_class: Asset class to look up, such as "pump". Case, whitespace, - underscores, and hyphens are normalized before querying. + digits, underscores, and hyphens are normalized before querying. """ raw_asset_class = asset_class key = _asset_class_key(asset_class) if not key or key == "none": return ErrorResult(error="asset_class is required") - if not fm_db: - return ErrorResult(error="CouchDB not connected") try: - try: - d = fm_db.get(f"fm:{key}") - except Exception: # noqa: BLE001 - d = None - if d is None: - res = fm_db.find({"asset_class": key}, limit=1) - docs = res["docs"] - if docs: - d = docs[0] + d = _find_failure_mode_doc(key) if d is None: return _missing_asset_class_error(raw_asset_class, key) return FailureModesResult( @@ -316,30 +329,43 @@ def get_failure_modes(asset_class: str) -> Union[FailureModesResult, ErrorResult return ErrorResult(error=str(exc)) -def _known_failure_modes(asset_class: str) -> List[str]: - """Return stored failure modes for an asset class, or [] if none are available.""" +def _find_failure_mode_doc(asset_class: str) -> Optional[dict]: + """Return the stored failure-mode doc for an asset class, or None.""" if not fm_db: - return [] + raise RuntimeError("database not connected") key = _asset_class_key(asset_class) try: - try: - d = fm_db.get(f"fm:{key}") - except Exception: # noqa: BLE001 + d = fm_db.get(f"fm:{key}", check=True) + except Exception as exc: # noqa: BLE001 + if _is_not_found_error(exc): d = None + else: + raise RuntimeError( + f"database lookup failed for asset_class '{key}': {exc}" + ) from exc + try: if d is None: res = fm_db.find({"asset_class": key}, limit=1) docs = res["docs"] if docs: d = docs[0] - if d is None: - return [] - return [ - mode.strip() - for mode in d.get("failure_modes", []) - if isinstance(mode, str) and mode.strip() - ] - except Exception: # noqa: BLE001 + return d + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + f"database lookup failed for asset_class '{key}': {exc}" + ) from exc + + +def _known_failure_modes(asset_class: str) -> List[str]: + """Return stored failure modes for an asset class, or [] if none are available.""" + d = _find_failure_mode_doc(asset_class) + if d is None: return [] + return [ + mode.strip() + for mode in d.get("failure_modes", []) + if isinstance(mode, str) and mode.strip() + ] @mcp.tool(title="Generate Failure Modes") @@ -349,15 +375,15 @@ def generate_failure_modes( ) -> Union[GenerateFailureModesResult, ErrorResult]: """GENERATE a new or extended failure-mode list for an asset class. - This tool does not write to CouchDB. If the normalized `asset_class` exists - in CouchDB, the current stored failure modes are used as context and the LLM - generates additional modes. If no stored modes exist, the LLM generates a - new list from scratch. + This tool does not write to the database. If the normalized `asset_class` + exists in the database, the current stored failure modes are used as context + and the LLM generates additional modes. If no stored modes exist, the LLM + generates a new list from scratch. Args: asset_class: Asset class to reason about, such as "pump". Case, - whitespace, underscores, and hyphens are normalized before prompting - the LLM. + whitespace, digits, underscores, and hyphens are normalized before + prompting the LLM. max_modes: Maximum number of new failure modes to request from the LLM. """ key = _asset_class_key(asset_class) @@ -368,10 +394,9 @@ def generate_failure_modes( if not _llm_available: return ErrorResult(error="LLM unavailable") - base = _known_failure_modes(key) - base = [mode.strip() for mode in base if mode and mode.strip()] - try: + base = _known_failure_modes(key) + base = [mode.strip() for mode in base if mode and mode.strip()] raw = _call_failure_mode_generation(key, base, max_modes) seen = {mode.lower() for mode in base} generated: List[str] = [] @@ -391,7 +416,7 @@ def generate_failure_modes( source=f"LLM:{_MODEL_ID}", message=( f"generated {len(generated)} new failure mode(s) for asset_class '{key}' " - f"using {len(base)} known mode(s) as context; nothing was persisted." + f"using {len(base)} stored mode(s) as context; nothing was persisted." ), ) except Exception as exc: # noqa: BLE001 @@ -399,6 +424,93 @@ def generate_failure_modes( return ErrorResult(error=str(exc)) +@mcp.tool(title="Add Failure Modes") +def add_failure_modes( + asset_class: str, + failure_modes: List[str], + exhaustive: Optional[bool] = None, + source: Optional[str] = None, +) -> Union[AddFailureModesResult, ErrorResult]: + """WRITE failure modes for an asset class to the database. + + Existing modes are preserved, incoming modes are merged case-insensitively, + and only newly added modes are reported. Use this after curated or generated + modes should become available to future `get_failure_modes` calls. + + Args: + asset_class: Asset class to update, such as "pump". Case, whitespace, + digits, underscores, and hyphens are normalized before writing. + failure_modes: Failure modes to add for the asset class. + exhaustive: Set true only when the stored list is believed complete. If + omitted, the existing value is preserved; new records default false. + source: Optional provenance for the stored list. + """ + key = _asset_class_key(asset_class) + if not key or key == "none": + return ErrorResult(error="asset_class is required") + incoming = [ + mode.strip() + for mode in (failure_modes or []) + if isinstance(mode, str) and mode.strip() + ] + if not incoming: + return ErrorResult(error="failure_modes list is required") + if not fm_db: + return ErrorResult(error="database not connected") + + try: + doc_id = f"fm:{key}" + doc = _find_failure_mode_doc(key) + existing = [ + mode.strip() + for mode in (doc or {}).get("failure_modes", []) + if isinstance(mode, str) and mode.strip() + ] + seen = set() + merged: List[str] = [] + for mode in existing: + normalized = mode.lower() + if normalized not in seen: + seen.add(normalized) + merged.append(mode) + + added: List[str] = [] + for mode in incoming: + normalized = mode.lower() + if normalized not in seen: + seen.add(normalized) + merged.append(mode) + added.append(mode) + + if doc is None: + doc = {"_id": doc_id, "asset_class": key} + stored_exhaustive = False + else: + doc.setdefault("_id", doc_id) + doc["asset_class"] = key + stored_exhaustive = bool(doc.get("exhaustive", False)) + doc["failure_modes"] = merged + doc["exhaustive"] = stored_exhaustive if exhaustive is None else exhaustive + doc["source"] = source or doc.get("source") or "user" + fm_db.save(doc) + + return AddFailureModesResult( + asset_class=key, + added=added, + failure_modes=merged, + total=len(merged), + exhaustive=doc["exhaustive"], + source=doc.get("source"), + message=( + f"added {len(added)} new failure mode(s) to asset_class '{key}' " + f"({len(merged)} total)." + ), + ) + except Exception as exc: # noqa: BLE001 + logger.error("add_failure_modes failed: %s", exc) + return ErrorResult(error=str(exc)) + + @mcp.tool(title="Generate Failure Mode Sensor Mapping") def generate_failure_mode_sensor_mapping( asset_class: str, @@ -411,7 +523,7 @@ def generate_failure_mode_sensor_mapping( Args: asset_class: Asset class to reason about, such as "pump". Case, whitespace, - underscores, and hyphens are normalized before prompting the LLM. + digits, underscores, and hyphens are normalized before prompting the LLM. failure_modes: Failure modes for the asset class. sensors: Sensor names to evaluate for detection relevance. """ diff --git a/src/servers/fmsr/tests/conftest.py b/src/servers/fmsr/tests/conftest.py index 2f2fafccf..37899e2fb 100644 --- a/src/servers/fmsr/tests/conftest.py +++ b/src/servers/fmsr/tests/conftest.py @@ -16,7 +16,7 @@ async def call_tool(mcp_instance, tool_name: str, args: dict) -> dict: return json.loads(contents[0].text) -class FakeCouchDB: +class FakeDatabase: def __init__(self, docs=None): self.docs = {doc["_id"]: dict(doc) for doc in docs or []} @@ -30,9 +30,11 @@ def find(self, selector, fields=None, limit=None): ] return {"docs": docs} - def get(self, doc_id): + def get(self, doc_id, *, check=False, default_value=None): if doc_id not in self.docs: - raise KeyError(doc_id) + if check: + raise KeyError(doc_id) + return default_value return dict(self.docs[doc_id]) def save(self, doc): @@ -50,6 +52,17 @@ def _matches(doc, selector): return True +class BrokenDatabase(FakeDatabase): + def find(self, selector, fields=None, limit=None): + raise RuntimeError("database read failed") + + def get(self, doc_id, *, check=False, default_value=None): + raise RuntimeError("database read failed") + + def save(self, doc): + raise RuntimeError("database write failed") + + @pytest.fixture def no_llm(): """Simulate missing WatsonX credentials.""" @@ -59,7 +72,7 @@ def no_llm(): @pytest.fixture def fake_fm_db(): - db = FakeCouchDB( + db = FakeDatabase( [ { "_id": "fm:pump", @@ -76,7 +89,14 @@ def fake_fm_db(): @pytest.fixture def empty_fm_db(): - db = FakeCouchDB() + db = FakeDatabase() + with patch("servers.fmsr.main.fm_db", db): + yield db + + +@pytest.fixture +def broken_fm_db(): + db = BrokenDatabase() with patch("servers.fmsr.main.fm_db", db): yield db diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 9cd0d2acc..352fd50be 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -52,7 +52,15 @@ async def test_db_unavailable_returns_error(self, monkeypatch): data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"}) - assert data == {"error": "CouchDB not connected"} + assert data == {"error": "database not connected"} + + @pytest.mark.anyio + async def test_database_read_error_returns_error(self, broken_fm_db): + data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"}) + + assert data == { + "error": "database lookup failed for asset_class 'pump': database read failed" + } class TestGenerateFailureModes: @@ -100,6 +108,21 @@ async def test_generates_from_scratch_for_missing_db_record( ] mock_failure_mode_generation.assert_called_once_with("compressor", [], 3) + @pytest.mark.anyio + async def test_database_read_error_returns_error( + self, broken_fm_db, mock_failure_mode_generation + ): + data = await call_tool( + mcp, + "generate_failure_modes", + {"asset_class": "pump", "max_modes": 3}, + ) + + assert data == { + "error": "database lookup failed for asset_class 'pump': database read failed" + } + mock_failure_mode_generation.assert_not_called() + @pytest.mark.anyio async def test_empty_asset_class_returns_error(self, mock_failure_mode_generation): data = await call_tool( @@ -146,6 +169,122 @@ async def test_integration(self): assert len(data["generated"]) <= 2 +class TestAddFailureModes: + @pytest.mark.anyio + async def test_merges_with_existing_modes(self, fake_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + { + "asset_class": "Pump-1", + "failure_modes": ["impeller wear", "bearing wear"], + "exhaustive": True, + "source": "unit-test", + }, + ) + + assert data["asset_class"] == "pump" + assert data["added"] == ["bearing wear"] + assert data["failure_modes"] == [ + "seal leakage", + "impeller wear", + "bearing wear", + ] + assert data["total"] == 3 + assert data["exhaustive"] is True + assert data["source"] == "unit-test" + assert fake_fm_db.docs["fm:pump"]["failure_modes"] == [ + "seal leakage", + "impeller wear", + "bearing wear", + ] + assert fake_fm_db.docs["fm:pump"]["exhaustive"] is True + assert fake_fm_db.docs["fm:pump"]["source"] == "unit-test" + + @pytest.mark.anyio + async def test_omitted_exhaustive_preserves_existing_value(self, fake_fm_db): + fake_fm_db.docs["fm:pump"]["exhaustive"] = True + + data = await call_tool( + mcp, + "add_failure_modes", + { + "asset_class": "pump", + "failure_modes": ["bearing wear"], + }, + ) + + assert data["exhaustive"] is True + assert fake_fm_db.docs["fm:pump"]["exhaustive"] is True + + @pytest.mark.anyio + async def test_creates_new_asset_class_record(self, empty_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + { + "asset_class": "Gearbox-1", + "failure_modes": ["gear tooth wear", "bearing wear"], + }, + ) + + assert data["asset_class"] == "gearbox" + assert data["added"] == ["gear tooth wear", "bearing wear"] + assert data["failure_modes"] == ["gear tooth wear", "bearing wear"] + assert data["total"] == 2 + assert data["exhaustive"] is False + assert data["source"] == "user" + assert empty_fm_db.docs["fm:gearbox"]["asset_class"] == "gearbox" + assert empty_fm_db.docs["fm:gearbox"]["failure_modes"] == [ + "gear tooth wear", + "bearing wear", + ] + + @pytest.mark.anyio + async def test_empty_asset_class_returns_error(self, fake_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + {"asset_class": "", "failure_modes": ["bearing wear"]}, + ) + + assert data == {"error": "asset_class is required"} + + @pytest.mark.anyio + async def test_empty_failure_modes_returns_error(self, fake_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + {"asset_class": "pump", "failure_modes": []}, + ) + + assert data == {"error": "failure_modes list is required"} + + @pytest.mark.anyio + async def test_db_unavailable_returns_error(self, monkeypatch): + monkeypatch.setattr("servers.fmsr.main.fm_db", None) + + data = await call_tool( + mcp, + "add_failure_modes", + {"asset_class": "pump", "failure_modes": ["bearing wear"]}, + ) + + assert data == {"error": "database not connected"} + + @pytest.mark.anyio + async def test_database_read_error_returns_error(self, broken_fm_db): + data = await call_tool( + mcp, + "add_failure_modes", + {"asset_class": "pump", "failure_modes": ["bearing wear"]}, + ) + + assert data == { + "error": "database lookup failed for asset_class 'pump': database read failed" + } + + _FAILURE_MODES = ["Compressor Overheating", "Condenser Water side fouling"] _SENSORS = ["Chiller 6 Power Input", "Chiller 6 Supply Temperature"] From 8f73cb2f059f084eb58558f4ddd736d3df26c513 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 16:57:00 -0400 Subject: [PATCH 13/16] Trim FMSR docs and server instructions Signed-off-by: Shuxin Lin --- docs/mcp-servers.md | 55 +++--------------------- docs/opencode-agent.md | 90 ++-------------------------------------- src/servers/fmsr/main.py | 26 +----------- 3 files changed, 11 insertions(+), 160 deletions(-) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 7ab2f1b29..4eb8d5c81 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -62,59 +62,16 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship **Requires:** LLM credentials for `generate_failure_modes` and `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads the database. **Failure-mode data:** `src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json` loaded into the `failure_mode` database collection. -The active FMSR surface is intentionally small: - -- `get_failure_modes` reads known data only. It does not call an LLM and does - not generate or persist new failure modes. -- `generate_failure_modes` calls an LLM to create a new failure-mode list or - extend existing modes for an `asset_class`. If the class exists in the database, - the stored list is used as context; otherwise, the LLM generates a new list - from scratch. It never writes to the database. -- `add_failure_modes` writes curated or generated modes to the database. It - merges with the current list and de-duplicates modes case-insensitively. -- `generate_failure_mode_sensor_mapping` calls an LLM to score supplied - `(failure_mode, sensor)` pairs. It does not write to the database. - -Use `asset_class` for FMSR calls, not `asset_name`. `asset_class` is the -equipment class stored in the FMEA data, such as `pump`, rather than a specific -asset id such as `Pump 3`. The server normalizes case, repeated whitespace, -underscores, hyphens, and digits before lookup or prompting, so values like -`Pump_3`, `pump-3`, and ` PUMP ` normalize to `pump`. If no matching -failure-mode record is found, the tool returns an error that includes the -normalized class and a short list of available `asset_class` values from the -database. - -Current failure-mode JSON rows use this shape: - -```json -{ - "asset_class": "pump", - "failure_modes": ["seal leakage", "impeller wear"], - "source": "synthetic sample", - "exhaustive": false -} -``` - -`generate_failure_modes` and `generate_failure_mode_sensor_mapping` use the FMSR -server's own model setting, `FMSR_MODEL_ID`, not the calling agent's -`--model-id`. The default is `watsonx/meta-llama/llama-3-3-70b-instruct`. To -share one model value with an agent run, set a shell variable and pass it to -both: - -```bash -MODEL_ID=tokenrouter/MiniMax-M3 - -FMSR_MODEL_ID="$MODEL_ID" \ -uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Call generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor']." -``` +FMSR tools take `asset_class`, not `asset_name`; the server normalizes case, +whitespace, digits, underscores, and hyphens. LLM-backed FMSR tools use +`FMSR_MODEL_ID`, independent of the calling agent model. | Tool | Category | Arguments | Description | | --------------------------------- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get_failure_modes` | read | `asset_class` | Return known failure modes for an asset class from the database. Returns `asset_class`, `failure_modes`, `exhaustive`, and `source`. | -| `generate_failure_modes` | read, LLM-use | `asset_class`, `max_modes?` | Generate a new or extended failure-mode list. Current database modes for the class are used as context when available. Returns `known`, `generated`, and combined `failure_modes`; does not edit the database. | -| `add_failure_modes` | write | `asset_class`, `failure_modes`, `exhaustive?`, `source?` | Persist/augment failure modes for an asset class. Merges with existing modes, returns `added`, `failure_modes`, `total`, `exhaustive`, and `source`. If `exhaustive` is omitted, existing records preserve their stored value and new records default to false. | -| `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_class`, `failure_modes`, `sensors` | For each (failure mode, sensor) pair, determine relevancy via LLM. Returns `metadata`, `fm2sensor`, `sensor2fm`, and `full_relevancy`; per-pair details include `relevancy_answer` and `relevancy_reason`, not `temporal_behavior`. | +| `generate_failure_modes` | read, LLM-use | `asset_class`, `max_modes?` | Generate or extend a failure-mode list without writing the database. | +| `add_failure_modes` | write | `asset_class`, `failure_modes`, `exhaustive?`, `source?` | Persist failure modes for an asset class. | +| `generate_failure_mode_sensor_mapping` | read, LLM-use | `asset_class`, `failure_modes`, `sensors` | Score failure-mode/sensor relevancy via LLM and return bidirectional mappings. | ## wo — Work Order diff --git a/docs/opencode-agent.md b/docs/opencode-agent.md index 840bfcd49..3cf86d3e4 100644 --- a/docs/opencode-agent.md +++ b/docs/opencode-agent.md @@ -201,100 +201,16 @@ models such as `MiniMax-M3` must be registered explicitly. ### FMSR tool model -`--model-id` controls the OpenCode agent model: the model that decides which -tools to call and writes the final answer. It does not automatically control -LLM calls made inside MCP servers. - -The FMSR tools `generate_failure_modes` and -`generate_failure_mode_sensor_mapping` make their own LLM calls, configured by -`FMSR_MODEL_ID` in `src/servers/fmsr/main.py`. To use the same router model for -both the OpenCode agent and the FMSR generation tools, set one shell variable -and pass it to both places: +FMSR tools take `asset_class`. `--model-id` controls OpenCode; LLM-backed FMSR +tools use `FMSR_MODEL_ID`. ```bash MODEL_ID=tokenrouter/MiniMax-M3 - FMSR_MODEL_ID="$MODEL_ID" \ uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Call generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor']." -``` - -The shared value must be valid for both systems. For example, -`tokenrouter/MiniMax-M3` can be used by both OpenCode routing and FMSR. An -OpenCode-only model such as `opencode/big-pickle` is not a valid FMSR backend. - -### FMSR smoke tests - -`get_failure_modes` reads the database and does not make an LLM call. It takes -`asset_class`, not `asset_name`; the current shared sample data includes -`asset_class: pump`. - -```bash -uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ - "Use the FMSR get_failure_modes tool for asset_class pump. Return only the asset_class, failure_modes, source, and exhaustive fields." -``` - -To check normalization, use mixed case, extra whitespace, digits, underscores, -or hyphens: - -```bash -uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ - "Use the FMSR get_failure_modes tool for asset_class ' Pump-1 '. Return the normalized asset_class and failure_modes." + "Use generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage'] and sensors ['Pressure sensor']." ``` -To check missing-class guidance: - -```bash -uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ - "Use the FMSR get_failure_modes tool for asset_class compressor and report the tool error exactly." -``` - -`generate_failure_modes` creates a new or extended list but does not edit the -database. If the normalized `asset_class` exists in the database, the stored -list is used as context; otherwise, the tool generates a new list from scratch: - -```bash -MODEL_ID=tokenrouter/MiniMax-M3 - -FMSR_MODEL_ID="$MODEL_ID" \ -uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_modes for asset_class pump with max_modes 5. Return known, generated, failure_modes, and message." -``` - -To generate from scratch for a class that is not currently in the failure-mode -database: - -```bash -MODEL_ID=tokenrouter/MiniMax-M3 - -FMSR_MODEL_ID="$MODEL_ID" \ -uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_modes for asset_class compressor with max_modes 4. Return known, generated, failure_modes, and message." -``` - -`add_failure_modes` writes to the database and does not call an LLM: - -```bash -uv run opencode-agent --model-id tokenrouter/MiniMax-M3 --show-trajectory \ - "Use add_failure_modes for asset_class pump with failure_modes ['bearing wear', 'seal leakage'], exhaustive false, and source 'manual smoke test'. Return asset_class, added, failure_modes, total, source, and message." -``` - -`generate_failure_mode_sensor_mapping` calls the FMSR backend model once per -failure-mode/sensor pair. Set `FMSR_MODEL_ID` when you want the mapping tool to -use the same TokenRouter model as the OpenCode agent: - -```bash -MODEL_ID=tokenrouter/MiniMax-M3 - -FMSR_MODEL_ID="$MODEL_ID" \ -uv run opencode-agent --model-id "$MODEL_ID" --show-trajectory \ - "Use generate_failure_mode_sensor_mapping for asset_class pump with failure_modes ['seal leakage', 'impeller wear'] and sensors ['Discharge pressure sensor', 'Motor current sensor', 'Vibration sensor', 'Flow sensor']. Return fm2sensor, sensor2fm, and full_relevancy." -``` - -The mapping response no longer includes `temporal_behavior`; per-pair entries -contain `asset_class`, `failure_mode`, `sensor`, `relevancy_answer`, and -`relevancy_reason`. - --- ## Permissions and web access diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index 2146ed66e..f91c7da99 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -1,20 +1,4 @@ -"""FMSR (Failure Mode and Sensor Reasoning) MCP Server. - -Tools: - get_failure_modes – READ the (partial) failure-mode list for an asset class from the database - generate_failure_modes – GENERATE a new/extended failure-mode list via the LLM without writing the database - add_failure_modes – WRITE: persist/augment an asset class's failure modes in the database - generate_failure_mode_sensor_mapping – GENERATE the bidirectional FM↔sensor relevancy via the LLM - -Failure modes live in the database (collection "failure_mode", one doc per asset class, -loaded from scenario manifests). Coverage is NOT exhaustive: docs carry -`exhaustive: false`, so get_failure_modes returns what is known. Retrieval (get_*), -failure-mode generation, database writes, and sensor-mapping generation are separate by design. - -LLM backend is configured via FMSR_MODEL_ID (default: watsonx/meta-llama/llama-3-3-70b-instruct). -Database via COUCHDB_URL / FAILURE_MODE_DBNAME / -COUCHDB_USERNAME / COUCHDB_PASSWORD. -""" +"""Failure mode and sensor reasoning for industrial asset classes.""" from __future__ import annotations @@ -292,13 +276,7 @@ class FailureModeSensorMappingResult(BaseModel): mcp = FastMCP( "fmsr", - instructions=( - "Failure mode and sensor reasoning. get_failure_modes READS an asset class's (possibly partial) " - "failure modes; generate_failure_modes GENERATES a new or extended list without writing the database; " - "add_failure_modes WRITES curated or generated modes to the database; " - "generate_failure_mode_sensor_mapping GENERATES which sensors can detect each failure. " - "Use the utilities MCP server for asset, sensor, and failure-mode catalog lookups." - ), + instructions="Failure mode and sensor reasoning for industrial asset classes.", ) From 393b21351339c0f6d4a2bb1912cc5c9924d97054 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 16:59:19 -0400 Subject: [PATCH 14/16] Remove FMSR model note from MCP docs Signed-off-by: Shuxin Lin --- docs/mcp-servers.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 4eb8d5c81..e1ffa6b9b 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -62,10 +62,6 @@ Synthetic motor vibration data (`asset_id: Motor_01`, from `motor_01.json`) ship **Requires:** LLM credentials for `generate_failure_modes` and `generate_failure_mode_sensor_mapping`; `get_failure_modes` reads the database. **Failure-mode data:** `src/couchdb/scenarios_data/shared/fmea/failure_modes_sample.json` loaded into the `failure_mode` database collection. -FMSR tools take `asset_class`, not `asset_name`; the server normalizes case, -whitespace, digits, underscores, and hyphens. LLM-backed FMSR tools use -`FMSR_MODEL_ID`, independent of the calling agent model. - | Tool | Category | Arguments | Description | | --------------------------------- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get_failure_modes` | read | `asset_class` | Return known failure modes for an asset class from the database. Returns `asset_class`, `failure_modes`, `exhaustive`, and `source`. | From b5a944788b195882414937dd7a5d7c26ce01d3b0 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 17:00:42 -0400 Subject: [PATCH 15/16] Clarify utilities catalog descriptions Signed-off-by: Shuxin Lin --- src/servers/utilities/main.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/servers/utilities/main.py b/src/servers/utilities/main.py index ffb5157c1..8a1b52067 100644 --- a/src/servers/utilities/main.py +++ b/src/servers/utilities/main.py @@ -37,16 +37,16 @@ user=COUCHDB_USERNAME, password=COUCHDB_PASSWORD, ) - logger.info("Connected to CouchDB catalog database: %s", CATALOG_DBNAME) + logger.info("Connected to catalog database: %s", CATALOG_DBNAME) except Exception as e: - logger.error("Failed to connect to CouchDB catalog database: %s", e) + logger.error("Failed to connect to catalog database: %s", e) catalog_db = None mcp = FastMCP( "utilities", instructions=( "General utilities: read JSON files, get current date/time, and query " - "asset, sensor, and failure-mode catalog data from CouchDB." + "asset, sensor, and failure-mode catalog data." ), ) @@ -162,7 +162,11 @@ def json_reader(file_name: str) -> str: def get_sensor_catalog( sensor: Optional[str] = None, ) -> Union[CatalogResult, ErrorResult]: - """Return sensor catalog entries. Pass sensor for an exact sensor-name lookup.""" + """Return cataloged sensor names and descriptions. + + Without a sensor argument, returns all cataloged sensor entries. Pass sensor + for an exact sensor-name lookup. + """ return _find_catalog( catalog_type="sensor", field="sensor", From 42dc0d8d369ed9516c4c0b3b8d0c3101b0d4cb2e Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Thu, 9 Jul 2026 17:05:39 -0400 Subject: [PATCH 16/16] Improve utilities catalog tool docstrings Signed-off-by: Shuxin Lin --- src/servers/utilities/main.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/servers/utilities/main.py b/src/servers/utilities/main.py index 8a1b52067..ae0ac2e25 100644 --- a/src/servers/utilities/main.py +++ b/src/servers/utilities/main.py @@ -162,10 +162,10 @@ def json_reader(file_name: str) -> str: def get_sensor_catalog( sensor: Optional[str] = None, ) -> Union[CatalogResult, ErrorResult]: - """Return cataloged sensor names and descriptions. + """Return cataloged sensor types. - Without a sensor argument, returns all cataloged sensor entries. Pass sensor - for an exact sensor-name lookup. + Entries contain `sensor` and `description`. Omit sensor to list all cataloged + sensor types, or pass sensor for an exact sensor-name lookup. """ return _find_catalog( catalog_type="sensor", @@ -180,7 +180,12 @@ def get_asset_catalog( asset: Optional[str] = None, category: Optional[str] = None, ) -> Union[CatalogResult, ErrorResult]: - """Return asset catalog entries. Pass asset and/or category for exact lookups.""" + """Return cataloged asset classes and categories. + + Entries contain `category`, `category_description`, `asset`, and + `description`. Omit filters to list all cataloged asset classes, or pass + asset and/or category for exact lookups. + """ return _find_catalog( catalog_type="asset", field="asset", @@ -195,9 +200,11 @@ def get_failure_mode_catalog( failure_mode: Optional[str] = None, category: Optional[str] = None, ) -> Union[CatalogResult, ErrorResult]: - """Return failure-mode catalog entries. + """Return cataloged failure modes by asset category. - Pass failure_mode and/or category for exact lookups. + Entries contain `category`, `failure_mode`, and `description`. Omit filters + to list all cataloged failure modes, or pass failure_mode and/or category + for exact lookups. """ return _find_catalog( catalog_type="failure_mode",