Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ fmsr-mcp-server = "servers.fmsr.main:main"
tsfm-mcp-server = "servers.tsfm.main:main"
wo-mcp-server = "servers.wo.main:main"
vibration-mcp-server = "servers.vibration.main:main"
robot-mcp-server = "servers.robot.main:main"
openai-agent = "agent.openai_agent.cli:main"
deep-agent = "agent.deep_agent.cli:main"
stirrup-agent = "agent.stirrup_agent.cli:main"
Expand Down
1 change: 1 addition & 0 deletions src/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"tsfm": "tsfm-mcp-server",
"wo": "wo-mcp-server",
"vibration": "vibration-mcp-server",
"robot": "robot-mcp-server",
}


Expand Down
53 changes: 18 additions & 35 deletions src/couchdb/init_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,52 +35,37 @@

_HERE = os.path.dirname(os.path.abspath(__file__))

SCENARIOS_DATA_DIR = os.environ.get("SCENARIOS_DATA_DIR", os.path.join(_HERE, "scenarios_data"))
SCENARIOS_DATA_DIR = os.environ.get("WO_SCENARIOS_DATA_DIR", os.path.join(_HERE, "scenarios_data"))
DEFAULT_MANIFEST_FILE = os.environ.get(
"DEFAULT_MANIFEST", os.path.join(SCENARIOS_DATA_DIR, "default", "manifest.json"))
"WO_DEFAULT_MANIFEST", os.path.join(SCENARIOS_DATA_DIR, "default.json"))


# --------------------------------------------------------------------------- #
# Scenario → manifest (filename convention, no scenarios .jsonl needed)
# --------------------------------------------------------------------------- #
def _load_default_manifest() -> tuple:
"""Return (manifest, base_dir) for the default manifest (scenarios_data/default/manifest.json)."""
def _load_default_manifest() -> dict:
if not os.path.isfile(DEFAULT_MANIFEST_FILE):
raise FileNotFoundError(
f"default manifest not found: {DEFAULT_MANIFEST_FILE}. "
"Create scenarios_data/default/manifest.json (or set DEFAULT_MANIFEST).")
"Create scenarios_data/default.json (or set WO_DEFAULT_MANIFEST).")
with open(DEFAULT_MANIFEST_FILE) as f:
return json.load(f), os.path.dirname(DEFAULT_MANIFEST_FILE)
return json.load(f)


def manifest_path(scenario_id) -> str:
folder = os.path.join(SCENARIOS_DATA_DIR, f"scenario_{scenario_id}", "manifest.json")
return folder if os.path.isfile(folder) else os.path.join(SCENARIOS_DATA_DIR, f"scenario_{scenario_id}.json")
return os.path.join(SCENARIOS_DATA_DIR, f"scenario_{scenario_id}.json")


def _resolve_manifest(scenario_id) -> tuple:
"""Return (manifest, base_dir).

scenario_id None → the default manifest. Otherwise the scenario's own manifest
(folder form scenario_<id>/manifest.json, or legacy flat scenario_<id>.json),
raising FileNotFoundError if it doesn't exist — an unknown id is never silently
treated as the default.

base_dir is the manifest's own folder; the loader resolves its relative data paths
against that folder, then its parent (so a sibling shared/ corpus is reachable), then
the couchdb dir. None → couchdb dir only (legacy flat manifests, unchanged).
"""
def _resolve_manifest(scenario_id) -> dict:
"""scenarios_data/scenario_<id>.json if present, else the default manifest."""
if scenario_id is None:
return _load_default_manifest()
path = manifest_path(scenario_id)
if not os.path.isfile(path):
raise FileNotFoundError(
f"no manifest for scenario {scenario_id}: expected "
f"{os.path.join(SCENARIOS_DATA_DIR, f'scenario_{scenario_id}', 'manifest.json')} "
f"or {os.path.join(SCENARIOS_DATA_DIR, f'scenario_{scenario_id}.json')}")
base_dir = os.path.dirname(path) if os.path.basename(path) == "manifest.json" else None
with open(path) as f:
return json.load(f), base_dir
if os.path.isfile(path):
with open(path) as f:
return json.load(f)
logger.info("No manifest %s — using default.", os.path.basename(path))
return _load_default_manifest()


# --------------------------------------------------------------------------- #
Expand All @@ -89,8 +74,7 @@ def _resolve_manifest(scenario_id) -> tuple:
def all_databases() -> list:
"""The databases this loader manages = the default manifest's keys (db name = key)."""
try:
manifest, _ = _load_default_manifest()
return list(manifest.keys())
return list(_load_default_manifest().keys())
except Exception:
return []

Expand All @@ -117,16 +101,15 @@ def init_data(scenario_id=None, force: bool = True, reset_first: bool = False,
managed_only: bool = False) -> dict:
"""Load a scenario's data (or the default) into CouchDB. Returns {collection: (db, n)}.

Resolves the manifest first, so an unknown ``scenario_id`` raises FileNotFoundError
before anything is dropped. ``reset_first=True`` then drops databases so collections
absent from the manifest are left empty rather than carrying over.
``reset_first=True`` drops databases first so collections absent from the manifest
are left empty rather than carrying over.
"""
manifest, base_dir = _resolve_manifest(scenario_id) # validate first (raises on unknown id)
if reset_first:
reset(managed_only=managed_only)
manifest = _resolve_manifest(scenario_id)
results = {}
for key, spec in manifest.items():
results[key] = loader.load_collection(key, spec, drop=force, base_dir=base_dir) # database name = key
results[key] = loader.load_collection(key, spec, drop=force) # database name = key
logger.info("Scenario %s: '%s' → %s (%d docs).", scenario_id, key, *results[key])
return results

Expand Down
23 changes: 5 additions & 18 deletions src/couchdb/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,27 +108,14 @@ def _parse_file(path, cfg) -> list:
return parse_csv(path, cfg) if cfg.get("format") == "csv" else parse_json(path)


def _collect_docs(key, source, cfg, base_dir=None) -> list:
"""Resolve a manifest source ("default"/path/dir/list/inline docs) to parsed docs.

Relative data paths resolve against the scenario folder (``base_dir``), then its parent
(so a sibling ``shared/`` corpus is reachable as ``shared/...``), then ``_HERE``. With
base_dir=None (legacy flat manifests) only ``_HERE`` is used, preserving behaviour.
"""
roots = ([base_dir, os.path.dirname(base_dir)] if base_dir else []) + [_HERE]
def _collect_docs(key, source, cfg) -> list:
"""Resolve a manifest source ("default"/path/dir/list/inline docs) to parsed docs."""
ext = ".csv" if cfg.get("format") == "csv" else ".json"

def _resolve(s):
for root in roots:
p = os.path.join(root, s)
if os.path.exists(p):
return p
return os.path.join(roots[0], s)

def files_from(s):
if s.strip().lower() == "default":
return sorted(glob.glob(os.path.join(SAMPLE_DATA_DIR, key, "*" + ext)))
p = s if os.path.isabs(s) else _resolve(s)
p = s if os.path.isabs(s) else os.path.join(_HERE, s)
if os.path.isdir(p):
return sorted(glob.glob(os.path.join(p, "*" + ext)))
return [p]
Expand Down Expand Up @@ -252,11 +239,11 @@ def _bulk_insert(db, docs, batch_size=500):
# --------------------------------------------------------------------------- #
# Public entry point
# --------------------------------------------------------------------------- #
def load_collection(key, source, drop=True, base_dir=None) -> tuple:
def load_collection(key, source, drop=True) -> tuple:
"""Load one collection's data into a database named after the key. Returns (db, n)."""
cfg = collection_config(key)
transform = _transform_for(key)
docs = [_normalise(d, key, cfg, transform) for d in _collect_docs(key, source, cfg, base_dir)]
docs = [_normalise(d, key, cfg, transform) for d in _collect_docs(key, source, cfg)]
db = key
if docs:
_ensure_db(db, drop=drop)
Expand Down
11 changes: 11 additions & 0 deletions src/couchdb/sample_data/failure_code/failure_code_sample.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
code,description
FC001,equipment does not start
FC002,equipment stops unexpectedly during operation
FC003,abnormal noise during operation
FC004,fluid leak observed
FC005,"excessive vibration, shaking, or instability"
FC006,overheating or high temperature reading
FC007,indicator light does not illuminate
FC008,gauge does not operate or is inaccurate
FC009,structural damage or cracking
FC010,part missing or loose
4 changes: 4 additions & 0 deletions src/couchdb/sample_data/work_order/workorders.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
wonum,siteid,description,description_longdescription,status,wopriority,worktype,assetnum,location,failurecode,parent,taskid,lead,reportedby,reportdate,schedstart,schedfinish,targstartdate,targcompdate,actfinish,estlabhrs,actlabhrs,estlabcost,actlabcost,estmatcost,actmatcost,estservcost,actservcost,esttoolcost,acttoolcost,estatapprtotalcost,esttotalcost,acttotalcost,wplabor,aob_asset_class,aob_source.agent,aob_source.trigger_type,aob_source.scenario_id,aob_source.utterance,aob_source.run_id,aob_source.evidence.sensor,aob_source.evidence.metric,aob_source.evidence.anomaly_score,aob_source.evidence.threshold,aob_source.evidence.observed_value,aob_source.evidence.failure_mode
1000045,MAIN,Investigate anomaly on Chiller 6 condenser water flow,,WAPPR,2,PdM,CHILLER6,MAIN-MECH-CH6,,,,,AGENT.TSFM,2020-04-28T09:15:00+00:00,,,2020-04-29T08:00:00+00:00,2020-04-30T17:00:00+00:00,,4.0,,,,,,,,,,,,,"[{""laborcode"":""HVACTECH1"",""craft"":""HVAC"",""laborhrs"":4.0,""startdate"":""2020-04-29T08:00:00+00:00""}]",Chiller,tsfm,anomaly_detection,WO-CHILLER6-ANOMALY-001,Generate a work order for Chiller 6 anomaly detection,run_2026-06-04_meta,Chiller 6 Condenser Water Flow,Condenser Water Flow,0.94,0.8,412.0,
1000046,MAIN,Bearing wear failure mode on Pump 3 - corrective action,,APPR,1,CM,PUMP3,MAIN-PUMPHOUSE,BEARING-WEAR,,,,AGENT.FMSR,2020-05-02T10:45:00+00:00,,,,2020-05-03T12:00:00+00:00,,6.0,,,,320.0,,,,,,,,,"[{""laborcode"":""MECHTECH2"",""craft"":""MECH"",""laborhrs"":6.0,""startdate"":""2020-05-03T07:00:00+00:00""}]",Pump,fmsr,failure_mode,WO-PUMP3-FMSR-002,Create a work order for the failure mode detected on Pump 3,,Pump 3 Vibration,,,,,Bearing Wear
1000050,NORTH,Quarterly preventive maintenance - AHU 2,,COMP,3,PM,AHU2,NORTH-ROOF,,,,,PLANNER1,2020-06-01T08:00:00+00:00,2020-06-15T08:00:00+00:00,2020-06-15T17:00:00+00:00,,,2020-06-15T16:30:00+00:00,8.0,7.5,,562.5,,95.0,,,,,,,657.5,"[{""laborcode"":""HVACTECH3"",""craft"":""HVAC"",""laborhrs"":8.0,""startdate"":""2020-06-15T08:00:00+00:00""}]",AHU,human,manual,WO-AHU2-PM-003,Schedule the quarterly PM for AHU 2,,,,,,,
10 changes: 10 additions & 0 deletions src/couchdb/scenarios_data/default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"workorder": "sample_data/work_order/workorders.csv",
"iot": [
"sample_data/iot/chiller_6.json",
"sample_data/iot/metro_pump_1.json",
"sample_data/iot/hydraulic_pump_1.json"
],
"vibration": "sample_data/iot/motor_01.json",
"failurecode": "sample_data/failure_code/failure_code_sample.csv"
}
7 changes: 7 additions & 0 deletions src/couchdb/scenarios_data/scenario_1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"workorder": "sample_data/work_order/workorders.csv",
"iot": [
"sample_data/iot/chiller_6.json",
"sample_data/iot/motor_01.json"
]
}
Empty file.
Empty file.
84 changes: 84 additions & 0 deletions src/couchdb/schema_robot_fields.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"doc_type": "asset_robot_profile",
"id_pattern": "profile:{normalized_asset_id}",
"description": "Per-asset profile documents for the robot inspection extension. Stored in the iot CouchDB database alongside timestamped sensor readings, but deliberately omit the asset_id field so existing IoT server queries are unaffected.",
"deferred_fields": "See SAFETY_FIELDS_FUTURE.md for hazard_class, maintenance_slot, active_work_order",

"fields": {
"physical_location": {
"type": "object or null",
"schema": {"x": "float", "y": "float", "z": "float", "room_id": "string"},
"default": null,
"status": "active",
"purpose": "Robot navigation target for navigate_to() tool. Set from facility floor plan. Null until floor-plan data is loaded."
},
"gauge_value": {
"type": "float",
"default": 0.0,
"status": "active",
"CRITICAL": "NEVER return this field from any MCP tool to the agent",
"set_by": "PhysicalStateSimulator.generate_scenario()",
"read_by": "Evaluator.get_ground_truth() ONLY",
"purpose": "Ground truth physical gauge reading. Set at scenario generation time. Must never reach the agent — doing so invalidates the evaluation."
},
"gauge_range": {
"type": "array [float, float]",
"default": [0, 100],
"status": "active",
"purpose": "Min/max of the gauge scale face. Used in PA metric and tau_agreement threshold calculation."
},
"panel_stuck_prob": {
"type": "float 0-1",
"default": 0.12,
"status": "active",
"purpose": "Probability that the access panel fails to open. SME-calibrated at 0.12 for preprogrammed navigation; 0.24-0.40 for learned navigation routes."
},
"human_present": {
"type": "bool",
"default": false,
"status": "active",
"purpose": "Whether a technician is currently at this asset. If true, robot dispatch is skipped and an alarm is raised to the person already on-site. Updated by PhysicalStateSimulator per scenario."
},
"never_read": {
"type": "bool",
"default": false,
"status": "active",
"purpose": "True if no physical gauge reading exists in Maximo history for this asset. Enables the never-read-gauge scenario variant. SME confirmed some gauges have never been recorded."
},
"real_gauge_images": {
"type": "array of strings",
"default": [],
"status": "active",
"purpose": "File paths to real facility images of this asset's gauge collected during field visits. Used to train and calibrate the CosmosWorld perception layer."
},
"reading_consistency": {
"type": "float or null",
"default": null,
"status": "active",
"purpose": "Empirical std(readings) / gauge_range computed from field collection data. Used to set tau_consistency threshold for this asset type. Null until field data arrives."
},
"sensor_physical_gap": {
"type": "float or null",
"default": null,
"status": "active",
"purpose": "Empirical |iot_value - gauge_value| / gauge_range from field collection. Calibrates tau_agreement for this asset. Null until field data arrives."
}
},

"indexes": [
{
"name": "idx_robot_never_read",
"fields": ["doc_type", "never_read"],
"purpose": "Scenario generator lookup for never-read gauge cases"
}
],

"known_profiles": [
{"profile_id": "profile:chiller_6", "display_name": "Chiller 6"},
{"profile_id": "profile:metro_pump_1", "display_name": "Metro Pump 1"},
{"profile_id": "profile:hydraulic_pump_1","display_name": "Hydraulic Pump 1"},
{"profile_id": "profile:motor_01", "display_name": "Motor 01"}
],

"isolation_guarantee": "Profile documents have no asset_id field. The IoT server queries {asset_id: {$exists: true}} — these documents are invisible to all existing server queries, including get_asset_list() and get_sensor_list()."
}
Loading