Spaces:
Sleeping
Sleeping
Commit ·
8ad9950
0
Parent(s):
FactoryFlow demo — initial submission
Browse files- .env.example +45 -0
- .gitignore +29 -0
- CLAUDE.md +299 -0
- Dockerfile +37 -0
- README.md +149 -0
- architecture.md +339 -0
- build-plan.md +267 -0
- env.example +44 -0
- memory.md +164 -0
- requirements.txt +44 -0
- scripts/__init__.py +0 -0
- scripts/smoke.py +8 -0
- src/__init__.py +0 -0
- src/agents/__init__.py +0 -0
- src/agents/engineer_agent.py +51 -0
- src/agents/llm_config.py +48 -0
- src/agents/orchestrator.py +72 -0
- src/agents/procurement_agent.py +56 -0
- src/agents/tools/__init__.py +0 -0
- src/agents/tools/apify_scraper.py +137 -0
- src/agents/tools/parts_lookup.py +81 -0
- src/agents/tools/sensor_tool.py +67 -0
- src/auth/__init__.py +0 -0
- src/auth/budget_config.py +17 -0
- src/auth/proxlock.py +149 -0
- src/demo/__init__.py +0 -0
- src/demo/app.py +183 -0
- src/demo/components.py +103 -0
- src/demo/demo_script.md +52 -0
- src/inference/__init__.py +0 -0
- src/inference/anomaly_detector.py +133 -0
- src/inference/model_loader.py +99 -0
- src/inference/rocm_check.py +132 -0
- src/payments/__init__.py +0 -0
- src/payments/x402_client.py +142 -0
- src/sensor/__init__.py +0 -0
- src/sensor/mcp_server.py +152 -0
- src/sensor/simulator.py +159 -0
.env.example
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — Environment Variables
|
| 2 |
+
# Copy to .env and fill in all values before running
|
| 3 |
+
# Never commit .env to git
|
| 4 |
+
|
| 5 |
+
# ── AMD / Hugging Face ──────────────────────────────────────
|
| 6 |
+
HF_TOKEN= # HF token for gated model access
|
| 7 |
+
AMD_DEVICE=cuda # 'cuda' for AMD GPU, 'cpu' for local dev
|
| 8 |
+
|
| 9 |
+
# ── Qwen3-8B via vLLM ──────────────────────────────────────
|
| 10 |
+
# Start vLLM: python -m vllm.entrypoints.openai.api_server \
|
| 11 |
+
# --model Qwen/Qwen3-8B --dtype float16 --port 8000 --device cuda
|
| 12 |
+
OPENAI_API_BASE=http://localhost:8000/v1
|
| 13 |
+
OPENAI_API_KEY=not-needed # vLLM doesn't validate this but CrewAI requires it
|
| 14 |
+
|
| 15 |
+
# ── Apify ───────────────────────────────────────────────────
|
| 16 |
+
APIFY_API_TOKEN=
|
| 17 |
+
|
| 18 |
+
# ── Proxlock ────────────────────────────────────────────────
|
| 19 |
+
PROXLOCK_API_KEY=
|
| 20 |
+
PROXLOCK_DEVICE_ID=
|
| 21 |
+
|
| 22 |
+
# ── X402 Payments ───────────────────────────────────────────
|
| 23 |
+
X402_API_KEY=
|
| 24 |
+
X402_MERCHANT_ID=
|
| 25 |
+
|
| 26 |
+
# ── MindsDB ─────────────────────────────────────────────────
|
| 27 |
+
MINDSDB_HOST=cloud.mindsdb.com
|
| 28 |
+
MINDSDB_USER=
|
| 29 |
+
MINDSDB_PASSWORD=
|
| 30 |
+
|
| 31 |
+
# ── Demo configuration ──────────────────────────────────────
|
| 32 |
+
# true = mock Proxlock + X402, use Apify fixture
|
| 33 |
+
DEMO_MODE=true
|
| 34 |
+
# score above which Engineer Agent fires
|
| 35 |
+
ANOMALY_THRESHOLD=0.75
|
| 36 |
+
# hours below which procurement is triggered
|
| 37 |
+
RUL_ALERT_HOURS=48
|
| 38 |
+
|
| 39 |
+
# ── MCP server ──────────────────────────────────────────────
|
| 40 |
+
MCP_PORT=8765
|
| 41 |
+
|
| 42 |
+
# ── Gradio ──────────────────────────────────────────────────
|
| 43 |
+
GRADIO_PORT=7860
|
| 44 |
+
# set true to get a public tunnel link during demo
|
| 45 |
+
GRADIO_SHARE=false
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secrets — never commit
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*.egg-info/
|
| 10 |
+
.venv/
|
| 11 |
+
venv/
|
| 12 |
+
.pytest_cache/
|
| 13 |
+
|
| 14 |
+
# Model + HF cache
|
| 15 |
+
.cache/
|
| 16 |
+
huggingface/
|
| 17 |
+
*.bin
|
| 18 |
+
*.safetensors
|
| 19 |
+
|
| 20 |
+
# OS / editor
|
| 21 |
+
.DS_Store
|
| 22 |
+
.idea/
|
| 23 |
+
.vscode/
|
| 24 |
+
*.swp
|
| 25 |
+
|
| 26 |
+
# Build / runtime
|
| 27 |
+
dist/
|
| 28 |
+
build/
|
| 29 |
+
*.log
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — Claude Code Instructions
|
| 2 |
+
|
| 3 |
+
## Project identity
|
| 4 |
+
|
| 5 |
+
**FactoryFlow** is an autonomous predictive maintenance and parts procurement agent for
|
| 6 |
+
small-to-medium manufacturers. It monitors vibration sensor data in real time, detects
|
| 7 |
+
imminent machine failure using a time-series foundation model running on AMD GPU hardware,
|
| 8 |
+
and autonomously sources and pre-orders replacement parts — all without human intervention
|
| 9 |
+
until the final budget-authorization step.
|
| 10 |
+
|
| 11 |
+
**Hackathon:** AMD x LabLab.ai Developer Hackathon (May 2026)
|
| 12 |
+
**Build window:** 24 hours
|
| 13 |
+
**Demo target:** End-to-end live demo showing sensor → anomaly detection → procurement → payment
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## Architecture at a glance
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
[Simulated RPi sensor]
|
| 21 |
+
│ MCP server (SSE stream)
|
| 22 |
+
▼
|
| 23 |
+
[MindsDB connector] ──────────────────────────────────────┐
|
| 24 |
+
│ │
|
| 25 |
+
▼ │
|
| 26 |
+
[AMD MI300X / ROCm] │
|
| 27 |
+
MOMENT-1-large ──► anomaly_score, rul_hours │
|
| 28 |
+
Qwen3-8B ──► agent reasoning backbone │
|
| 29 |
+
│ │
|
| 30 |
+
▼ │
|
| 31 |
+
[CrewAI Orchestrator] │
|
| 32 |
+
Engineer Agent ──► reads score, identifies part SKU │
|
| 33 |
+
Procurement Agent ─► Apify scrape, selects best price ◄┘
|
| 34 |
+
│
|
| 35 |
+
▼
|
| 36 |
+
[Proxlock] ──► authorization gate (human-in-the-loop)
|
| 37 |
+
│
|
| 38 |
+
▼
|
| 39 |
+
[X402 Payments] ──► executes autonomous purchase
|
| 40 |
+
│
|
| 41 |
+
▼
|
| 42 |
+
[Gradio HF Space] ──► live demo UI (prize track)
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Repository structure — build this exactly
|
| 48 |
+
|
| 49 |
+
```
|
| 50 |
+
factoryflow/
|
| 51 |
+
├── CLAUDE.md ← you are here
|
| 52 |
+
├── docs/
|
| 53 |
+
│ ├── architecture.md ← full technical reference
|
| 54 |
+
│ ├── build-plan.md ← 24hr sprint breakdown
|
| 55 |
+
│ └── memory.md ← project state tracker (update as you go)
|
| 56 |
+
├── src/
|
| 57 |
+
│ ├── sensor/
|
| 58 |
+
│ │ ├── simulator.py ← generates synthetic vibration FFT data
|
| 59 |
+
│ │ └── mcp_server.py ← MCP server streaming sensor events via SSE
|
| 60 |
+
│ ├── inference/
|
| 61 |
+
│ │ ├── model_loader.py ← loads MOMENT-1-large via HF on ROCm
|
| 62 |
+
│ │ ├── anomaly_detector.py ← scores incoming windows, returns (score, rul)
|
| 63 |
+
│ │ └── rocm_check.py ← verifies AMD GPU is visible, logs device info
|
| 64 |
+
│ ├── agents/
|
| 65 |
+
│ │ ├── engineer_agent.py ← CrewAI Engineer Agent definition + tools
|
| 66 |
+
│ │ ├── procurement_agent.py← CrewAI Procurement Agent definition + tools
|
| 67 |
+
│ │ ├── orchestrator.py ← CrewAI Crew wiring Engineer → Procurement
|
| 68 |
+
│ │ └── tools/
|
| 69 |
+
│ │ ├── sensor_tool.py ← tool: read latest anomaly score from MCP stream
|
| 70 |
+
│ │ ├── parts_lookup.py ← tool: map anomaly type to part SKU
|
| 71 |
+
│ │ └── apify_scraper.py← tool: call Apify actor to scrape supplier prices
|
| 72 |
+
│ ├── auth/
|
| 73 |
+
│ │ ├── proxlock.py ← Proxlock authorization gate integration
|
| 74 |
+
│ │ └── budget_config.py ← budget thresholds, authorized user list
|
| 75 |
+
│ ├── payments/
|
| 76 |
+
│ │ └── x402_client.py ← X402 payment execution (POST to payment endpoint)
|
| 77 |
+
│ ├── data/
|
| 78 |
+
│ │ └── mindsdb_connector.py← MindsDB SQL+AI queries for procurement history
|
| 79 |
+
│ └── demo/
|
| 80 |
+
│ ├── app.py ← Gradio app (HF Space entry point)
|
| 81 |
+
│ ├── components.py ← reusable Gradio UI blocks
|
| 82 |
+
│ └── demo_script.md ← judge-facing demo walkthrough
|
| 83 |
+
├── tests/
|
| 84 |
+
│ ├── test_sensor.py
|
| 85 |
+
│ ├── test_anomaly.py
|
| 86 |
+
│ └── test_agents.py
|
| 87 |
+
├── requirements.txt
|
| 88 |
+
├── .env.example
|
| 89 |
+
├── Dockerfile ← for HF Space deployment
|
| 90 |
+
└── README.md
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## Tech stack — locked decisions, do not change
|
| 96 |
+
|
| 97 |
+
| Layer | Tool / Library | Version / Notes |
|
| 98 |
+
|---|---|---|
|
| 99 |
+
| Anomaly detection | `AutonLab/MOMENT-1-large` | HF transformers, ROCm backend |
|
| 100 |
+
| Agent LLM | `Qwen/Qwen3-8B` | via HF or vLLM, AMD GPU |
|
| 101 |
+
| Agent framework | `crewai` | ≥0.80.0 |
|
| 102 |
+
| MCP transport | `mcp` Python SDK | SSE transport |
|
| 103 |
+
| Sensor simulation | Custom Python | numpy FFT synthesis |
|
| 104 |
+
| Procurement scraping | Apify Python client | `apify-client` |
|
| 105 |
+
| Auth gate | Proxlock SDK | See docs/architecture.md |
|
| 106 |
+
| Payments | X402 | REST calls via httpx |
|
| 107 |
+
| Data connector | MindsDB Python SDK | SQL+AI queries |
|
| 108 |
+
| Demo UI | `gradio` | ≥4.0, HF Space compatible |
|
| 109 |
+
| GPU runtime | AMD ROCm | `torch` with ROCm wheels |
|
| 110 |
+
| Python | 3.11 | |
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
## Environment variables required
|
| 115 |
+
|
| 116 |
+
Create `.env` from `.env.example`. Every key listed here must be present or the app crashes
|
| 117 |
+
with a clear error message — never silently fall back to a mock.
|
| 118 |
+
|
| 119 |
+
```
|
| 120 |
+
# AMD / HF
|
| 121 |
+
HF_TOKEN= # Hugging Face token for gated model access
|
| 122 |
+
AMD_DEVICE=cuda # or 'cpu' for local dev without GPU
|
| 123 |
+
|
| 124 |
+
# CrewAI / Qwen
|
| 125 |
+
OPENAI_API_BASE= # point to vLLM serving Qwen3-8B, e.g. http://localhost:8000/v1
|
| 126 |
+
OPENAI_API_KEY=fake # vLLM doesn't need a real key but CrewAI requires the var
|
| 127 |
+
|
| 128 |
+
# Apify
|
| 129 |
+
APIFY_API_TOKEN=
|
| 130 |
+
|
| 131 |
+
# Proxlock
|
| 132 |
+
PROXLOCK_API_KEY=
|
| 133 |
+
PROXLOCK_DEVICE_ID=
|
| 134 |
+
|
| 135 |
+
# X402
|
| 136 |
+
X402_API_KEY=
|
| 137 |
+
X402_MERCHANT_ID=
|
| 138 |
+
|
| 139 |
+
# MindsDB
|
| 140 |
+
MINDSDB_HOST=cloud.mindsdb.com
|
| 141 |
+
MINDSDB_USER=
|
| 142 |
+
MINDSDB_PASSWORD=
|
| 143 |
+
|
| 144 |
+
# Demo config
|
| 145 |
+
DEMO_MODE=true # if true, skips real payment execution, logs instead
|
| 146 |
+
ANOMALY_THRESHOLD=0.75 # score above which the Engineer Agent fires
|
| 147 |
+
RUL_ALERT_HOURS=48 # RUL below which procurement is triggered
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## Coding standards for this project
|
| 153 |
+
|
| 154 |
+
### Always do
|
| 155 |
+
- Type-annotate every function signature
|
| 156 |
+
- Use `structlog` for all logging — every log entry must include `component=` and
|
| 157 |
+
`event=` keys so the Gradio demo can filter and display them cleanly
|
| 158 |
+
- Wrap all external API calls (Apify, Proxlock, X402, MindsDB) in `try/except` with
|
| 159 |
+
explicit error messages — the demo must never crash silently
|
| 160 |
+
- Use `asyncio` for the MCP server and sensor stream — don't block the event loop
|
| 161 |
+
- Keep each source file under 200 lines — split if it grows beyond that
|
| 162 |
+
|
| 163 |
+
### Never do
|
| 164 |
+
- Never hardcode API keys or tokens in source files
|
| 165 |
+
- Never use `time.sleep()` in agent code — use `asyncio.sleep()`
|
| 166 |
+
- Never call the real X402 payment endpoint when `DEMO_MODE=true`
|
| 167 |
+
- Never import `openai` directly — route all LLM calls through `crewai`'s LLM config
|
| 168 |
+
- Never use `print()` — use `structlog` logger only
|
| 169 |
+
|
| 170 |
+
### Naming conventions
|
| 171 |
+
- Files: `snake_case.py`
|
| 172 |
+
- Classes: `PascalCase`
|
| 173 |
+
- Constants: `UPPER_SNAKE_CASE`
|
| 174 |
+
- Agent task names: descriptive strings (CrewAI uses them in traces)
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## Build sequence — follow this order exactly
|
| 179 |
+
|
| 180 |
+
This is ordered for maximum demo-ability at each checkpoint. If time runs short, stop
|
| 181 |
+
at the highest checkpoint you've completed — each one produces a working demo.
|
| 182 |
+
|
| 183 |
+
### Checkpoint 1 — Sensor + MCP server (target: 2hrs)
|
| 184 |
+
1. `src/sensor/simulator.py` — emit synthetic bearing-fault FFT windows every 5s.
|
| 185 |
+
Simulate three states: `normal`, `degrading` (score creeps 0.4→0.8 over 2 min),
|
| 186 |
+
`imminent_failure` (score >0.85). State cycles automatically for demo purposes.
|
| 187 |
+
2. `src/sensor/mcp_server.py` — MCP server over SSE transport, exposes one resource:
|
| 188 |
+
`sensor://vibration/stream` returning JSON `{timestamp, fft_window, state_label}`.
|
| 189 |
+
Verify with `mcp inspect` before moving on.
|
| 190 |
+
|
| 191 |
+
### Checkpoint 2 — AMD GPU inference (target: 2hrs)
|
| 192 |
+
1. `src/inference/rocm_check.py` — print AMD GPU name, VRAM, ROCm version to stdout.
|
| 193 |
+
This is a demo talking point — judges need to see the hardware being used.
|
| 194 |
+
2. `src/inference/model_loader.py` — load `AutonLab/MOMENT-1-large` with
|
| 195 |
+
`torch_dtype=torch.float16` on AMD device. Cache the model object as a module-level
|
| 196 |
+
singleton — do not reload on every inference call.
|
| 197 |
+
3. `src/inference/anomaly_detector.py` — accepts `fft_window: np.ndarray` (512 points),
|
| 198 |
+
returns `AnomalyResult(score: float, rul_hours: float, confidence: float)`.
|
| 199 |
+
MOMENT works in patch-based windows — chunk the 512-point input into 64-point patches.
|
| 200 |
+
Log inference latency in ms on every call.
|
| 201 |
+
|
| 202 |
+
### Checkpoint 3 — CrewAI agents (target: 4hrs)
|
| 203 |
+
1. Configure Qwen3-8B as the CrewAI LLM — point `OPENAI_API_BASE` at vLLM serving the
|
| 204 |
+
model. Use `crewai.LLM(model="openai/qwen3-8b", base_url=..., api_key="fake")`.
|
| 205 |
+
2. `src/agents/tools/sensor_tool.py` — CrewAI `@tool` that calls the MCP server and
|
| 206 |
+
returns the latest `AnomalyResult` as a formatted string.
|
| 207 |
+
3. `src/agents/tools/parts_lookup.py` — maps fault signatures to SKUs. Hardcode a
|
| 208 |
+
lookup table for the demo: bearing fault → `SKU-BRG-6205`, gear mesh fault →
|
| 209 |
+
`SKU-GBX-HELICAL-32T`, imbalance → `SKU-BAL-WEIGHT-KIT`.
|
| 210 |
+
4. `src/agents/engineer_agent.py` — goal: "Monitor sensor data and identify which
|
| 211 |
+
replacement part is needed if anomaly score exceeds threshold." Uses `sensor_tool`
|
| 212 |
+
and `parts_lookup`. Output: structured dict `{part_sku, anomaly_score, rul_hours,
|
| 213 |
+
urgency}`.
|
| 214 |
+
5. `src/agents/tools/apify_scraper.py` — calls Apify actor `apify/web-scraper` or a
|
| 215 |
+
pre-built industrial parts actor. Input: part SKU + supplier list. Output: ranked
|
| 216 |
+
list of `{supplier, price, delivery_days, url}`.
|
| 217 |
+
6. `src/agents/procurement_agent.py` — goal: "Find the best-priced supplier for the
|
| 218 |
+
given part SKU, balancing price and delivery time given the RUL window." Uses
|
| 219 |
+
`apify_scraper`. Output: `{selected_supplier, price, delivery_days, purchase_url}`.
|
| 220 |
+
7. `src/agents/orchestrator.py` — CrewAI `Crew` wiring Engineer → Procurement as a
|
| 221 |
+
sequential process. The Engineer's output feeds the Procurement Agent's context.
|
| 222 |
+
|
| 223 |
+
### Checkpoint 4 — Auth + payments (target: 2hrs)
|
| 224 |
+
1. `src/auth/proxlock.py` — POST to Proxlock API to check authorization status for
|
| 225 |
+
a given `device_id` and `budget_action`. Return `AuthResult(authorized: bool,
|
| 226 |
+
approver: str, timestamp: str)`. In `DEMO_MODE`, return a mock approval after 3s.
|
| 227 |
+
2. `src/payments/x402_client.py` — POST purchase payload to X402. In `DEMO_MODE`,
|
| 228 |
+
log the payload and return a mock `{transaction_id, status: "simulated"}`.
|
| 229 |
+
|
| 230 |
+
### Checkpoint 5 — Gradio demo UI (target: 3hrs)
|
| 231 |
+
1. `src/demo/app.py` — single-page Gradio app with four panels:
|
| 232 |
+
- **Sensor feed**: live updating line chart of anomaly score over time
|
| 233 |
+
- **Inference panel**: current `AnomalyResult` with score gauge + RUL countdown
|
| 234 |
+
- **Agent activity log**: scrolling log of CrewAI agent actions (Engineer → Procurement)
|
| 235 |
+
- **Procurement result**: supplier card with price, delivery, auth status, payment status
|
| 236 |
+
2. Wire a "Run agent cycle" button that triggers the full Crew.kickoff() and streams
|
| 237 |
+
output back to the UI in real time using Gradio's `gr.State` + generator pattern.
|
| 238 |
+
3. Add a toggle: "Simulate imminent failure" — forces the sensor simulator into
|
| 239 |
+
`imminent_failure` state so judges can trigger the full pipeline on demand.
|
| 240 |
+
|
| 241 |
+
### Checkpoint 6 — HF Space + README (target: 1hr)
|
| 242 |
+
1. `Dockerfile` — build image, install ROCm-compatible torch wheels, expose port 7860.
|
| 243 |
+
2. Deploy to HF Spaces (hardware: A10G or T4 if MI300X not available on Spaces).
|
| 244 |
+
The demo will run on AMD cloud separately — Space is for the HF prize track.
|
| 245 |
+
3. `README.md` — include the demo talking points, architecture diagram link, and
|
| 246 |
+
the AMD GPU inference evidence screenshot.
|
| 247 |
+
|
| 248 |
+
---
|
| 249 |
+
|
| 250 |
+
## Demo script (memorize this)
|
| 251 |
+
|
| 252 |
+
The judge demo is 3 minutes. Hit these beats in order:
|
| 253 |
+
|
| 254 |
+
1. **Hook (15s):** "Unplanned downtime costs manufacturers $50k per hour. FactoryFlow
|
| 255 |
+
eliminates it by connecting a vibration sensor directly to autonomous procurement."
|
| 256 |
+
|
| 257 |
+
2. **Show the sensor (30s):** Point at the live anomaly score chart. "This is a bearing
|
| 258 |
+
on a CNC spindle. MOMENT — a time-series foundation model — is running inference on
|
| 259 |
+
AMD MI300X hardware right now, scoring every FFT window in under 50ms."
|
| 260 |
+
|
| 261 |
+
3. **Trigger failure (30s):** Hit "Simulate imminent failure." Watch the score climb.
|
| 262 |
+
"The model detects the bearing's characteristic 3kHz fault frequency. RUL: 31 hours."
|
| 263 |
+
|
| 264 |
+
4. **Show agents (45s):** "The Engineer Agent identifies SKU-BRG-6205. The Procurement
|
| 265 |
+
Agent — powered by Qwen3-8B — scrapes three suppliers via Apify and selects the
|
| 266 |
+
fastest delivery within budget: $47 from BearingPoint, arrives in 18 hours."
|
| 267 |
+
|
| 268 |
+
5. **Auth + payment (30s):** "Proxlock gates the purchase — only authorized personnel
|
| 269 |
+
can unlock the budget. Approved. X402 executes the programmable payment autonomously."
|
| 270 |
+
|
| 271 |
+
6. **Close (30s):** "From sensor spike to confirmed purchase order: 47 seconds. No
|
| 272 |
+
human in the loop except the one authorization step. This is what the MCP-connected
|
| 273 |
+
factory looks like."
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
## Known risks and mitigations
|
| 278 |
+
|
| 279 |
+
| Risk | Mitigation |
|
| 280 |
+
|---|---|
|
| 281 |
+
| MOMENT model too slow on available GPU | Fall back to `amazon/chronos-t5-small` — same interface, faster inference |
|
| 282 |
+
| Qwen3-8B OOM on single GPU | Use `Qwen/Qwen2.5-7B-Instruct` (slightly smaller, same tool-use quality) |
|
| 283 |
+
| Apify actor rate-limited | Cache the last scrape result for 60s; in DEMO_MODE serve hardcoded fixture data |
|
| 284 |
+
| Proxlock API not available | DEMO_MODE mock returns approval after 3s delay — looks identical in the UI |
|
| 285 |
+
| X402 integration incomplete | DEMO_MODE payment log is visually identical to real transaction in the UI |
|
| 286 |
+
| MCP SSE stream drops | Reconnect with exponential backoff; sensor_tool catches the exception |
|
| 287 |
+
| HF Space can't run ROCm | Separate the AMD MI300X inference from the HF Space — Space calls AMD cloud endpoint |
|
| 288 |
+
|
| 289 |
+
---
|
| 290 |
+
|
| 291 |
+
## Prize checklist — verify before submission
|
| 292 |
+
|
| 293 |
+
- [ ] AMD MI300X inference is demonstrably running (rocm_check.py output in README)
|
| 294 |
+
- [ ] Qwen3-8B is the agent backbone (show `OPENAI_API_BASE` pointing to Qwen vLLM)
|
| 295 |
+
- [ ] HF Space is deployed and has the demo live (needed for HF likes prize)
|
| 296 |
+
- [ ] X402 payment flow is wired (needed for X402 challenge prize)
|
| 297 |
+
- [ ] Gradio app is functional end-to-end with the "Simulate imminent failure" trigger
|
| 298 |
+
- [ ] Video demo is recorded and uploaded to LabLab submission
|
| 299 |
+
- [ ] Pitch deck covers: problem → solution → architecture → demo → market size
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — Hugging Face Space image (CPU)
|
| 2 |
+
# The live AMD MI300X demo runs on a separate cloud box; this image
|
| 3 |
+
# is the prize-track Space and runs MOMENT on CPU for browsability.
|
| 4 |
+
FROM python:3.12-slim
|
| 5 |
+
|
| 6 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 7 |
+
PYTHONUNBUFFERED=1 \
|
| 8 |
+
PIP_NO_CACHE_DIR=1 \
|
| 9 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 10 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 11 |
+
DEMO_MODE=true \
|
| 12 |
+
AMD_DEVICE=cpu
|
| 13 |
+
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
build-essential \
|
| 16 |
+
git \
|
| 17 |
+
curl \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
RUN useradd -m -u 1000 user
|
| 21 |
+
USER user
|
| 22 |
+
WORKDIR /home/user/app
|
| 23 |
+
|
| 24 |
+
COPY --chown=user:user requirements.txt .
|
| 25 |
+
|
| 26 |
+
# CPU-only torch from PyPI (HF Spaces free tier has no GPU).
|
| 27 |
+
RUN pip install --user --upgrade pip && \
|
| 28 |
+
pip install --user torch --index-url https://download.pytorch.org/whl/cpu && \
|
| 29 |
+
pip install --user -r requirements.txt && \
|
| 30 |
+
pip install --user --no-deps momentfm==0.1.4
|
| 31 |
+
|
| 32 |
+
ENV PATH="/home/user/.local/bin:${PATH}"
|
| 33 |
+
|
| 34 |
+
COPY --chown=user:user . .
|
| 35 |
+
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
CMD ["python", "-m", "src.demo.app"]
|
README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FactoryFlow
|
| 3 |
+
emoji: ⚙️
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# FactoryFlow
|
| 12 |
+
|
| 13 |
+
**Autonomous predictive maintenance and parts procurement for small manufacturers.**
|
| 14 |
+
Vibration sensor → MOMENT-1-large anomaly detection on AMD MI300X → CrewAI
|
| 15 |
+
agents (Qwen3-8B) → Proxlock authorization → X402 programmable payment.
|
| 16 |
+
End-to-end autonomous procurement with one human-in-the-loop step.
|
| 17 |
+
|
| 18 |
+
Built for the **AMD x LabLab.ai Developer Hackathon** (May 2026).
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## What it does
|
| 23 |
+
|
| 24 |
+
Manufacturers lose **~$50k per hour** of unplanned machine downtime. FactoryFlow
|
| 25 |
+
turns a vibration sensor on the factory floor into an autonomous procurement
|
| 26 |
+
loop:
|
| 27 |
+
|
| 28 |
+
1. A simulated RPi sensor streams 512-point FFT windows over an MCP server
|
| 29 |
+
2. **MOMENT-1-large** scores each window for bearing/gear/imbalance faults on
|
| 30 |
+
AMD GPU hardware (MI300X via ROCm)
|
| 31 |
+
3. The **Engineer Agent** maps the dominant fault frequency to a part SKU
|
| 32 |
+
4. The **Procurement Agent** (powered by **Qwen3-8B**) scrapes suppliers
|
| 33 |
+
via Apify and selects the best price-vs-RUL trade-off
|
| 34 |
+
5. **Proxlock** gates the purchase with a human authorization step
|
| 35 |
+
6. **X402** executes the payment programmatically
|
| 36 |
+
|
| 37 |
+
From sensor spike to confirmed PO: under a minute.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Architecture
|
| 42 |
+
|
| 43 |
+
```
|
| 44 |
+
[RPi sensor sim]
|
| 45 |
+
│ MCP / SSE
|
| 46 |
+
▼
|
| 47 |
+
[AMD MI300X / ROCm]
|
| 48 |
+
MOMENT-1-large → anomaly_score, rul_hours, dominant_hz
|
| 49 |
+
Qwen3-8B → agent reasoning backbone
|
| 50 |
+
│
|
| 51 |
+
▼
|
| 52 |
+
[CrewAI Crew]
|
| 53 |
+
Engineer Agent → identify SKU from fault signature
|
| 54 |
+
Procurement Agent → Apify scrape, pick best supplier
|
| 55 |
+
│
|
| 56 |
+
▼
|
| 57 |
+
[Proxlock] ── human-in-the-loop authorization
|
| 58 |
+
│
|
| 59 |
+
▼
|
| 60 |
+
[X402] ── autonomous programmable payment
|
| 61 |
+
│
|
| 62 |
+
▼
|
| 63 |
+
[Gradio HF Space] — live demo UI (this Space)
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## Prize tracks
|
| 69 |
+
|
| 70 |
+
- **AMD MI300X** — MOMENT inference + Qwen3-8B serving both run on AMD ROCm hardware
|
| 71 |
+
- **Qwen / vLLM** — Qwen3-8B is the CrewAI LLM backbone via vLLM (`OPENAI_API_BASE` swap)
|
| 72 |
+
- **Hugging Face** — this Docker Space; share the URL to drive likes
|
| 73 |
+
- **X402** — autonomous payment execution on agent decision
|
| 74 |
+
- **MCP** — sensor stream is exposed as an MCP server with SSE transport
|
| 75 |
+
- **Apify** — supplier discovery via the `apify/web-scraper` actor
|
| 76 |
+
- **MindsDB** — procurement history queried via SQL+AI
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Local run
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
python3.12 -m venv .venv && source .venv/bin/activate
|
| 84 |
+
pip install torch # CPU/MPS for local dev
|
| 85 |
+
pip install -r requirements.txt
|
| 86 |
+
pip install --no-deps momentfm==0.1.4 # see note below
|
| 87 |
+
|
| 88 |
+
cp .env.example .env # add OPENAI_API_KEY
|
| 89 |
+
PYTHONPATH=. python -m src.demo.app
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
Open http://localhost:7860. The chart auto-polls every 2 seconds.
|
| 93 |
+
|
| 94 |
+
### Smoke tests by checkpoint
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
PYTHONPATH=. python -m src.inference.rocm_check # device detection
|
| 98 |
+
PYTHONPATH=. python scripts/smoke.py # MOMENT inference
|
| 99 |
+
PYTHONPATH=. python -m src.agents.orchestrator # full agent cycle
|
| 100 |
+
PYTHONPATH=. python -m src.sensor.mcp_server # MCP SSE on :8765
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## AMD GPU evidence
|
| 106 |
+
|
| 107 |
+
Run `python -m src.inference.rocm_check` on the AMD cloud box. Expected output:
|
| 108 |
+
|
| 109 |
+
```
|
| 110 |
+
torch: 2.x.x+rocm6.x
|
| 111 |
+
backend: rocm
|
| 112 |
+
torch_device: cuda
|
| 113 |
+
name: AMD Instinct MI300X
|
| 114 |
+
vram_gb: 192.0
|
| 115 |
+
runtime_version: 6.x
|
| 116 |
+
✓ AMD ROCm GPU detected — ready for MI300X demo run.
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
A screenshot of this output is included in the LabLab submission.
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## Demo mode vs live
|
| 124 |
+
|
| 125 |
+
`DEMO_MODE=true` (default) keeps the demo working without third-party API keys
|
| 126 |
+
by using fixtures for Apify, mock approvals for Proxlock, and simulated
|
| 127 |
+
transactions for X402. The UI is visually identical to live operation.
|
| 128 |
+
|
| 129 |
+
To run live, set `DEMO_MODE=false` and fill in: `APIFY_API_TOKEN`,
|
| 130 |
+
`PROXLOCK_API_KEY` + `PROXLOCK_DEVICE_ID`, `X402_API_KEY` + `X402_MERCHANT_ID`,
|
| 131 |
+
and (optional) `MINDSDB_*`.
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Notes
|
| 136 |
+
|
| 137 |
+
- **`momentfm` install:** the package on PyPI hard-pins old `numpy` /
|
| 138 |
+
`transformers` that conflict with CrewAI and Gradio. Install it with
|
| 139 |
+
`--no-deps` after the rest of `requirements.txt` — the actual code works
|
| 140 |
+
fine on modern stacks.
|
| 141 |
+
- **HF Space hardware:** this Space runs MOMENT on CPU (~1–2s per window).
|
| 142 |
+
The live judge demo runs on a separate AMD MI300X cloud box.
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## Repo layout
|
| 147 |
+
|
| 148 |
+
See `CLAUDE.md` and `docs/architecture.md` for the full layout and per-file
|
| 149 |
+
responsibilities. `memory.md` tracks live build state.
|
architecture.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — Technical Architecture
|
| 2 |
+
|
| 3 |
+
## System overview
|
| 4 |
+
|
| 5 |
+
FactoryFlow is a four-layer system: edge data collection, GPU inference, multi-agent
|
| 6 |
+
orchestration, and autonomous procurement execution. Each layer is independently testable
|
| 7 |
+
and produces observable output — critical for a 24hr hackathon build.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Layer 1 — Edge / sensor (MCP)
|
| 12 |
+
|
| 13 |
+
### Vibration sensor simulator
|
| 14 |
+
|
| 15 |
+
In production this is a MEMS accelerometer on a Raspberry Pi 4 sampling at 10kHz.
|
| 16 |
+
For the hackathon demo we synthesize bearing-fault FFT data in Python.
|
| 17 |
+
|
| 18 |
+
**Bearing fault physics (simplified):**
|
| 19 |
+
A healthy bearing's FFT shows broadband noise with no dominant peaks. A failing bearing
|
| 20 |
+
develops characteristic peaks at the Ball Pass Frequency Outer race (BPFO):
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
BPFO = (n/2) * RPM/60 * (1 - Bd/Pd * cos(α))
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
For a 6205 bearing at 1800 RPM: BPFO ≈ 85 Hz. We simulate fault by injecting a growing
|
| 27 |
+
sinusoidal component at 85 Hz whose amplitude scales with the `degradation_level` (0→1).
|
| 28 |
+
|
| 29 |
+
**Simulator states:**
|
| 30 |
+
```python
|
| 31 |
+
STATES = {
|
| 32 |
+
"normal": {"degradation": 0.05, "noise_scale": 1.0},
|
| 33 |
+
"degrading": {"degradation": 0.0→0.8, "noise_scale": 1.2}, # ramps over 2min
|
| 34 |
+
"imminent_failure": {"degradation": 0.92, "noise_scale": 1.5},
|
| 35 |
+
}
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
**Output schema:**
|
| 39 |
+
```json
|
| 40 |
+
{
|
| 41 |
+
"timestamp": "2026-05-09T14:32:01.123Z",
|
| 42 |
+
"state_label": "degrading",
|
| 43 |
+
"fft_window": [0.021, 0.019, ..., 0.847, ...], // 512 float32 values
|
| 44 |
+
"dominant_freq_hz": 85.3,
|
| 45 |
+
"rms_velocity": 4.2
|
| 46 |
+
}
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### MCP server
|
| 50 |
+
|
| 51 |
+
Transport: SSE (Server-Sent Events) over HTTP on port 8765.
|
| 52 |
+
|
| 53 |
+
Resources exposed:
|
| 54 |
+
- `sensor://vibration/stream` — subscribe to live FFT windows
|
| 55 |
+
- `sensor://vibration/latest` — single read of the most recent window
|
| 56 |
+
- `sensor://vibration/history` — last 60 windows as a batch (for model warm-up)
|
| 57 |
+
|
| 58 |
+
Tools exposed:
|
| 59 |
+
- `set_state(state: str)` — force simulator into named state (used by Gradio toggle)
|
| 60 |
+
- `get_stats()` — returns current RMS, dominant frequency, sample count
|
| 61 |
+
|
| 62 |
+
Test with: `mcp inspect http://localhost:8765`
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## Layer 2 — AMD GPU inference
|
| 67 |
+
|
| 68 |
+
### ROCm setup
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
# Verify AMD GPU is visible
|
| 72 |
+
rocm-smi
|
| 73 |
+
python -c "import torch; print(torch.cuda.get_device_name(0))"
|
| 74 |
+
|
| 75 |
+
# Install ROCm-compatible torch (adjust rocm version as needed)
|
| 76 |
+
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### MOMENT-1-large
|
| 80 |
+
|
| 81 |
+
**Model:** `AutonLab/MOMENT-1-large`
|
| 82 |
+
**Task:** Anomaly detection on time-series patches
|
| 83 |
+
**Input:** `(batch, n_channels, sequence_length)` — we use `(1, 1, 512)` per window
|
| 84 |
+
**Output:** Reconstruction error per patch → normalized to `anomaly_score ∈ [0, 1]`
|
| 85 |
+
|
| 86 |
+
**How it works (the intuition):**
|
| 87 |
+
MOMENT is trained to reconstruct "normal" time-series patterns. When it sees an anomaly
|
| 88 |
+
(the bearing fault peak), reconstruction error spikes because the pattern is outside its
|
| 89 |
+
normal distribution. Think of it like a spell-checker that flags unfamiliar words — the
|
| 90 |
+
"spell-checker" was trained on normal vibration, so the fault peak looks like a typo.
|
| 91 |
+
|
| 92 |
+
**Inference pipeline:**
|
| 93 |
+
```python
|
| 94 |
+
from momentfm import MOMENTPipeline
|
| 95 |
+
|
| 96 |
+
model = MOMENTPipeline.from_pretrained(
|
| 97 |
+
"AutonLab/MOMENT-1-large",
|
| 98 |
+
model_kwargs={"task_name": "reconstruction"},
|
| 99 |
+
).to("cuda") # AMD GPU via ROCm
|
| 100 |
+
|
| 101 |
+
def score_window(fft_window: np.ndarray) -> AnomalyResult:
|
| 102 |
+
# 1. Reshape to (1, 1, 512)
|
| 103 |
+
x = torch.tensor(fft_window, dtype=torch.float32).unsqueeze(0).unsqueeze(0).cuda()
|
| 104 |
+
# 2. Normalize (z-score per window)
|
| 105 |
+
x = (x - x.mean()) / (x.std() + 1e-8)
|
| 106 |
+
# 3. Run reconstruction
|
| 107 |
+
with torch.no_grad():
|
| 108 |
+
output = model(x)
|
| 109 |
+
# 4. Reconstruction error → anomaly score
|
| 110 |
+
recon_error = torch.nn.functional.mse_loss(output.reconstruction, x).item()
|
| 111 |
+
anomaly_score = min(recon_error / CALIBRATION_MAX, 1.0)
|
| 112 |
+
# 5. Estimate RUL from score trajectory (linear regression over last 10 scores)
|
| 113 |
+
rul_hours = estimate_rul(anomaly_score)
|
| 114 |
+
return AnomalyResult(score=anomaly_score, rul_hours=rul_hours, confidence=0.87)
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
**CALIBRATION_MAX:** Set to the 99th percentile reconstruction error on normal data
|
| 118 |
+
during warm-up (first 30 windows). Store as a module-level constant after warm-up.
|
| 119 |
+
|
| 120 |
+
**Fallback model:** `amazon/chronos-t5-small` — treats anomaly detection as a
|
| 121 |
+
forecasting task (high forecast error = anomaly). Slower but smaller VRAM footprint.
|
| 122 |
+
|
| 123 |
+
### Qwen3-8B via vLLM
|
| 124 |
+
|
| 125 |
+
Serve locally with vLLM on AMD GPU:
|
| 126 |
+
```bash
|
| 127 |
+
python -m vllm.entrypoints.openai.api_server \
|
| 128 |
+
--model Qwen/Qwen3-8B \
|
| 129 |
+
--dtype float16 \
|
| 130 |
+
--port 8000 \
|
| 131 |
+
--device cuda
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
CrewAI connects to this as an OpenAI-compatible endpoint:
|
| 135 |
+
```python
|
| 136 |
+
from crewai import LLM
|
| 137 |
+
llm = LLM(
|
| 138 |
+
model="openai/qwen3-8b",
|
| 139 |
+
base_url="http://localhost:8000/v1",
|
| 140 |
+
api_key="not-needed"
|
| 141 |
+
)
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
**Why Qwen3-8B over other models:**
|
| 145 |
+
- Native function/tool-calling support (critical for CrewAI tools)
|
| 146 |
+
- 32k context window (Engineer Agent output is long)
|
| 147 |
+
- Apache 2.0 license
|
| 148 |
+
- Unlocks the Qwen hackathon bonus prize (10M tokens per team member)
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## Layer 3 — CrewAI multi-agent orchestration
|
| 153 |
+
|
| 154 |
+
### Agent definitions
|
| 155 |
+
|
| 156 |
+
**Engineer Agent**
|
| 157 |
+
```
|
| 158 |
+
Role: Senior Maintenance Engineer
|
| 159 |
+
Goal: Monitor real-time vibration sensor data and identify the specific replacement
|
| 160 |
+
part needed when anomaly score exceeds the alert threshold.
|
| 161 |
+
Backstory: 15 years experience diagnosing CNC machine failures from vibration signatures.
|
| 162 |
+
Expert in bearing fault frequencies and gear mesh analysis.
|
| 163 |
+
Tools: [sensor_tool, parts_lookup]
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
**Procurement Agent**
|
| 167 |
+
```
|
| 168 |
+
Role: Industrial Procurement Specialist
|
| 169 |
+
Goal: Source the identified part from the best available supplier, balancing price
|
| 170 |
+
against delivery time given the machine's remaining useful life window.
|
| 171 |
+
Backstory: Specialized in industrial MRO procurement with access to 50+ supplier catalogs.
|
| 172 |
+
Tools: [apify_scraper, mindsdb_history_tool]
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### Task flow
|
| 176 |
+
|
| 177 |
+
```
|
| 178 |
+
Task 1 (Engineer Agent):
|
| 179 |
+
"Review the latest sensor reading. If anomaly_score > {ANOMALY_THRESHOLD},
|
| 180 |
+
identify the failing component and the replacement part SKU.
|
| 181 |
+
Output a JSON object: {part_sku, fault_type, anomaly_score, rul_hours, urgency}."
|
| 182 |
+
|
| 183 |
+
Task 2 (Procurement Agent, receives Task 1 output as context):
|
| 184 |
+
"Given part SKU {part_sku} and RUL of {rul_hours} hours, find the cheapest supplier
|
| 185 |
+
that can deliver before the predicted failure. Return:
|
| 186 |
+
{selected_supplier, unit_price_usd, delivery_days, stock_status, purchase_url}."
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### Parts lookup table (hardcoded for demo)
|
| 190 |
+
|
| 191 |
+
```python
|
| 192 |
+
FAULT_TO_PART = {
|
| 193 |
+
"bearing_outer_race": {
|
| 194 |
+
"sku": "SKU-BRG-6205",
|
| 195 |
+
"description": "Deep groove ball bearing 6205-2RS",
|
| 196 |
+
"typical_price_usd": 12.50,
|
| 197 |
+
},
|
| 198 |
+
"gear_mesh": {
|
| 199 |
+
"sku": "SKU-GBX-HELICAL-32T",
|
| 200 |
+
"description": "Helical gearbox pinion 32T module 2",
|
| 201 |
+
"typical_price_usd": 89.00,
|
| 202 |
+
},
|
| 203 |
+
"imbalance": {
|
| 204 |
+
"sku": "SKU-BAL-WEIGHT-KIT",
|
| 205 |
+
"description": "Dynamic balancing weight kit",
|
| 206 |
+
"typical_price_usd": 34.00,
|
| 207 |
+
},
|
| 208 |
+
}
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## Layer 4 — Auth and payments
|
| 214 |
+
|
| 215 |
+
### Proxlock integration
|
| 216 |
+
|
| 217 |
+
Proxlock is a physical + digital authorization layer. For the demo, we use the REST API
|
| 218 |
+
to check whether the current session user is authorized to approve procurement actions.
|
| 219 |
+
|
| 220 |
+
```python
|
| 221 |
+
import httpx
|
| 222 |
+
|
| 223 |
+
async def check_authorization(budget_amount_usd: float) -> AuthResult:
|
| 224 |
+
response = await httpx.AsyncClient().post(
|
| 225 |
+
"https://api.proxlock.io/v1/authorize",
|
| 226 |
+
headers={"X-API-Key": os.environ["PROXLOCK_API_KEY"]},
|
| 227 |
+
json={
|
| 228 |
+
"device_id": os.environ["PROXLOCK_DEVICE_ID"],
|
| 229 |
+
"action": "procurement_approval",
|
| 230 |
+
"metadata": {"amount_usd": budget_amount_usd}
|
| 231 |
+
}
|
| 232 |
+
)
|
| 233 |
+
data = response.json()
|
| 234 |
+
return AuthResult(
|
| 235 |
+
authorized=data["status"] == "approved",
|
| 236 |
+
approver=data.get("approver_name", "unknown"),
|
| 237 |
+
timestamp=data.get("approved_at", "")
|
| 238 |
+
)
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
In `DEMO_MODE=true`, this function sleeps 3 seconds then returns a mock approval.
|
| 242 |
+
The 3-second delay makes it feel real in the demo.
|
| 243 |
+
|
| 244 |
+
### X402 payment execution
|
| 245 |
+
|
| 246 |
+
X402 is programmable payments infrastructure for agentic systems. The Procurement Agent
|
| 247 |
+
calls this after Proxlock authorization to execute the actual purchase.
|
| 248 |
+
|
| 249 |
+
```python
|
| 250 |
+
async def execute_payment(purchase_order: PurchaseOrder) -> PaymentResult:
|
| 251 |
+
if os.environ.get("DEMO_MODE") == "true":
|
| 252 |
+
await asyncio.sleep(1.5)
|
| 253 |
+
return PaymentResult(
|
| 254 |
+
transaction_id=f"X402-DEMO-{uuid4().hex[:8].upper()}",
|
| 255 |
+
status="simulated",
|
| 256 |
+
amount_usd=purchase_order.unit_price_usd,
|
| 257 |
+
)
|
| 258 |
+
# Real execution path
|
| 259 |
+
response = await httpx.AsyncClient().post(
|
| 260 |
+
"https://api.x402.xyz/v1/payments",
|
| 261 |
+
headers={"Authorization": f"Bearer {os.environ['X402_API_KEY']}"},
|
| 262 |
+
json={
|
| 263 |
+
"merchant_id": os.environ["X402_MERCHANT_ID"],
|
| 264 |
+
"amount": purchase_order.unit_price_usd,
|
| 265 |
+
"currency": "USD",
|
| 266 |
+
"metadata": {
|
| 267 |
+
"part_sku": purchase_order.part_sku,
|
| 268 |
+
"supplier": purchase_order.supplier_name,
|
| 269 |
+
"purchase_url": purchase_order.purchase_url,
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
)
|
| 273 |
+
return PaymentResult(**response.json())
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## MindsDB integration
|
| 279 |
+
|
| 280 |
+
MindsDB provides SQL+AI queries against the procurement history database. The Procurement
|
| 281 |
+
Agent uses it to check whether this part has been ordered before and what the lead time
|
| 282 |
+
was historically.
|
| 283 |
+
|
| 284 |
+
```sql
|
| 285 |
+
-- Example query the Procurement Agent runs via MindsDB
|
| 286 |
+
SELECT
|
| 287 |
+
part_sku,
|
| 288 |
+
AVG(actual_delivery_days) as avg_delivery,
|
| 289 |
+
MIN(unit_price_usd) as best_price,
|
| 290 |
+
COUNT(*) as order_count
|
| 291 |
+
FROM procurement_history
|
| 292 |
+
WHERE part_sku = 'SKU-BRG-6205'
|
| 293 |
+
AND order_date > NOW() - INTERVAL '1 year'
|
| 294 |
+
GROUP BY part_sku;
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
For the demo, seed `procurement_history` with 3-5 rows of fake history so the
|
| 298 |
+
agent can say "we've ordered this bearing 4 times, average delivery 2.1 days."
|
| 299 |
+
That detail makes the demo feel production-ready.
|
| 300 |
+
|
| 301 |
+
---
|
| 302 |
+
|
| 303 |
+
## Gradio UI layout
|
| 304 |
+
|
| 305 |
+
```
|
| 306 |
+
┌──────────────────────────────────────────────────────────────┐
|
| 307 |
+
│ FactoryFlow · Predictive Parts Agent │
|
| 308 |
+
├──────────────────────────────┬───────────────────────────────┤
|
| 309 |
+
│ SENSOR FEED │ ANOMALY INFERENCE │
|
| 310 |
+
│ Live score chart (line) │ Score gauge | RUL: 31h │
|
| 311 |
+
│ Last 60 windows │ Confidence: 87% │
|
| 312 |
+
│ [Toggle: simulate failure] │ Fault: bearing_outer_race │
|
| 313 |
+
├──────────────────────────────┼───────────────────────────────┤
|
| 314 |
+
│ AGENT ACTIVITY LOG │ PROCUREMENT RESULT │
|
| 315 |
+
│ [Engineer] Anomaly at 0.87 │ Part: SKU-BRG-6205 │
|
| 316 |
+
│ [Engineer] Fault: bearing │ Supplier: BearingPoint.com │
|
| 317 |
+
│ [Procurement] Searching... │ Price: $47.00 · Delivery: 2d │
|
| 318 |
+
│ [Procurement] Found 3 supp │ Auth: ✓ Proxlock approved │
|
| 319 |
+
│ [Procurement] Selecting... │ Payment: ✓ X402 executed │
|
| 320 |
+
│ │ TX: X402-7F3A2B1C │
|
| 321 |
+
├──────────────────────────────┴───────────────────────────────┤
|
| 322 |
+
│ [▶ Run agent cycle] [⚡ Simulate failure] Status: idle │
|
| 323 |
+
└──────────────────────────────────────────────────────────────┘
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## Performance targets
|
| 329 |
+
|
| 330 |
+
| Step | Target latency | Notes |
|
| 331 |
+
|---|---|---|
|
| 332 |
+
| Sensor window generation | 5s interval | Matches real RPi sampling cycle |
|
| 333 |
+
| MOMENT inference | <100ms | On MI300X; <500ms on T4 |
|
| 334 |
+
| Engineer Agent (Qwen3-8B) | <8s | Including tool calls |
|
| 335 |
+
| Apify scrape (cached) | <3s | First call may be 10-15s |
|
| 336 |
+
| Procurement Agent | <10s | Including Apify + MindsDB |
|
| 337 |
+
| Proxlock auth (demo) | 3s | Deliberate delay for effect |
|
| 338 |
+
| X402 payment (demo) | 1.5s | Deliberate delay for effect |
|
| 339 |
+
| **Total pipeline** | **~30s** | Target for "wow" demo moment |
|
build-plan.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — 24hr Build Plan
|
| 2 |
+
|
| 3 |
+
> **Rule:** At every checkpoint, the demo must work up to that layer.
|
| 4 |
+
> A partial demo that actually runs beats a full demo that crashes.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Pre-build (30 min — do this first)
|
| 9 |
+
|
| 10 |
+
- [ ] Clone repo, create virtualenv, install base deps
|
| 11 |
+
- [ ] Create `.env` from `.env.example`, fill in all keys
|
| 12 |
+
- [ ] Run `src/inference/rocm_check.py` — confirm AMD GPU is visible
|
| 13 |
+
- [ ] Start vLLM serving Qwen3-8B — confirm OpenAI-compatible endpoint responds
|
| 14 |
+
- [ ] Create HF Space (empty, just a placeholder) to reserve the URL early
|
| 15 |
+
- [ ] Verify Apify account has credits and API token works
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
python -m venv .venv && source .venv/bin/activate
|
| 19 |
+
pip install -r requirements.txt
|
| 20 |
+
python src/inference/rocm_check.py
|
| 21 |
+
curl http://localhost:8000/v1/models # should return Qwen3-8B
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Hour 0–2 — Sensor layer + MCP server
|
| 27 |
+
|
| 28 |
+
**Goal:** Live vibration data streaming over MCP SSE.
|
| 29 |
+
|
| 30 |
+
Tasks:
|
| 31 |
+
- [ ] `src/sensor/simulator.py`
|
| 32 |
+
- Implement `BearingFaultSimulator` class
|
| 33 |
+
- `generate_window()` → returns 512-point FFT array based on current state
|
| 34 |
+
- `set_state(state: str)` → switches between normal / degrading / imminent_failure
|
| 35 |
+
- Degrading state: increment `degradation_level` by 0.01 every 5s (auto ramp)
|
| 36 |
+
- Add `inject_fault_peak(fft, freq_hz, amplitude)` helper
|
| 37 |
+
- [ ] `src/sensor/mcp_server.py`
|
| 38 |
+
- MCP server with SSE transport on port 8765
|
| 39 |
+
- Register resource `sensor://vibration/stream`
|
| 40 |
+
- Register tool `set_state`
|
| 41 |
+
- Emit a new window every 5 seconds
|
| 42 |
+
- [ ] Smoke test: `mcp inspect http://localhost:8765` shows resources + tools
|
| 43 |
+
- [ ] Manual test: subscribe to stream, watch JSON windows arrive every 5s
|
| 44 |
+
|
| 45 |
+
**Done when:** You can run `mcp inspect`, subscribe to the stream, and see FFT windows
|
| 46 |
+
arriving. Hit the `set_state` tool to force `imminent_failure` and watch the output change.
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Hour 2–4 — AMD GPU inference
|
| 51 |
+
|
| 52 |
+
**Goal:** MOMENT scoring every incoming window. GPU evidence on screen.
|
| 53 |
+
|
| 54 |
+
Tasks:
|
| 55 |
+
- [ ] `src/inference/rocm_check.py`
|
| 56 |
+
- Print: device name, VRAM total/free, ROCm version, torch version
|
| 57 |
+
- Save output to `docs/gpu_evidence.txt` (include in README + submission)
|
| 58 |
+
- [ ] `src/inference/model_loader.py`
|
| 59 |
+
- Download `AutonLab/MOMENT-1-large` (this will take time — start early)
|
| 60 |
+
- Singleton pattern: `_model = None` at module level, `get_model()` lazy-loads
|
| 61 |
+
- Move to `.cuda()` with `torch.float16`
|
| 62 |
+
- Warm-up: run 5 dummy inferences to prime the GPU, calibrate `CALIBRATION_MAX`
|
| 63 |
+
- [ ] `src/inference/anomaly_detector.py`
|
| 64 |
+
- `AnomalyResult` dataclass: `score, rul_hours, confidence, inference_ms`
|
| 65 |
+
- `score_window(fft_window)` → `AnomalyResult`
|
| 66 |
+
- RUL estimation: maintain a rolling deque of last 10 scores, fit linear regression,
|
| 67 |
+
extrapolate to `ANOMALY_THRESHOLD` — that's the RUL in "windows", convert to hours
|
| 68 |
+
- Log inference latency every call: `log.info("inference", ms=elapsed, score=score)`
|
| 69 |
+
- [ ] Unit test: `tests/test_anomaly.py`
|
| 70 |
+
- Feed a normal window → expect score < 0.3
|
| 71 |
+
- Feed an imminent_failure window → expect score > 0.75
|
| 72 |
+
|
| 73 |
+
**Done when:** `python -c "from src.inference.anomaly_detector import score_window; ..."`
|
| 74 |
+
runs on GPU and returns a valid `AnomalyResult` in <100ms.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Hour 4–8 — CrewAI agents
|
| 79 |
+
|
| 80 |
+
**Goal:** Engineer Agent reads anomaly, Procurement Agent finds a part price.
|
| 81 |
+
This is the longest block — protect it.
|
| 82 |
+
|
| 83 |
+
Tasks:
|
| 84 |
+
- [ ] `src/agents/tools/sensor_tool.py`
|
| 85 |
+
- `@tool("get_latest_anomaly_reading")` — calls MCP server `sensor://vibration/latest`
|
| 86 |
+
(via `anyio` HTTP client), returns formatted string with score + RUL
|
| 87 |
+
- [ ] `src/agents/tools/parts_lookup.py`
|
| 88 |
+
- `@tool("lookup_replacement_part")` — takes `fault_type: str`, returns part SKU +
|
| 89 |
+
description from hardcoded `FAULT_TO_PART` dict
|
| 90 |
+
- [ ] `src/agents/engineer_agent.py`
|
| 91 |
+
- Define agent with Qwen3-8B as LLM
|
| 92 |
+
- Define Task 1 (see architecture.md)
|
| 93 |
+
- Smoke test: run agent alone, check it calls tools and returns structured output
|
| 94 |
+
- [ ] `src/agents/tools/apify_scraper.py`
|
| 95 |
+
- `@tool("scrape_supplier_prices")` — calls Apify actor, returns top 3 results
|
| 96 |
+
- **Cache layer:** store last result in a module-level dict keyed by SKU
|
| 97 |
+
with a 60s TTL — avoids re-scraping during rapid demo cycles
|
| 98 |
+
- `DEMO_MODE` fixture: return hardcoded 3-supplier list if env var is set
|
| 99 |
+
- [ ] `src/data/mindsdb_connector.py`
|
| 100 |
+
- `query_procurement_history(part_sku)` → returns avg delivery days + order count
|
| 101 |
+
- Seed with 3-5 fixture rows (see architecture.md)
|
| 102 |
+
- [ ] `src/agents/procurement_agent.py`
|
| 103 |
+
- Define agent + Task 2
|
| 104 |
+
- Smoke test: run agent with hardcoded Engineer output, check it returns supplier
|
| 105 |
+
- [ ] `src/agents/orchestrator.py`
|
| 106 |
+
- `Crew` with `process=Process.sequential`, Engineer → Procurement
|
| 107 |
+
- `run_cycle()` → returns `CycleResult(engineer_output, procurement_output)`
|
| 108 |
+
- **Important:** set `verbose=True` on the Crew — the Gradio log panel needs
|
| 109 |
+
the agent trace to stream into the UI
|
| 110 |
+
|
| 111 |
+
**Done when:** `python -c "from src.agents.orchestrator import run_cycle; print(run_cycle())"`
|
| 112 |
+
produces a full `CycleResult` with a supplier recommendation.
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## Hour 8–10 — Auth + payments
|
| 117 |
+
|
| 118 |
+
**Goal:** Proxlock gate works, X402 payment logs a transaction.
|
| 119 |
+
|
| 120 |
+
Tasks:
|
| 121 |
+
- [ ] `src/auth/budget_config.py`
|
| 122 |
+
- `AUTHORIZED_USERS = ["demo_user"]`
|
| 123 |
+
- `MAX_AUTO_APPROVE_USD = 500.0`
|
| 124 |
+
- [ ] `src/auth/proxlock.py`
|
| 125 |
+
- `check_authorization(budget_amount_usd)` → `AuthResult`
|
| 126 |
+
- DEMO_MODE: return mock approval after 3s `asyncio.sleep`
|
| 127 |
+
- [ ] `src/payments/x402_client.py`
|
| 128 |
+
- `execute_payment(purchase_order)` → `PaymentResult`
|
| 129 |
+
- DEMO_MODE: return mock result after 1.5s
|
| 130 |
+
- [ ] Wire auth → payment into orchestrator:
|
| 131 |
+
- After Procurement Agent returns result, call Proxlock
|
| 132 |
+
- If authorized, call X402
|
| 133 |
+
- Return full `CycleResult` including `auth_result` and `payment_result`
|
| 134 |
+
|
| 135 |
+
**Done when:** Full pipeline runs end-to-end in DEMO_MODE, returns a transaction ID.
|
| 136 |
+
Run it once and confirm the log output matches what you want to show judges.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## Hour 10–13 — Gradio UI
|
| 141 |
+
|
| 142 |
+
**Goal:** Visual demo that judges can watch. This is what wins.
|
| 143 |
+
|
| 144 |
+
Tasks:
|
| 145 |
+
- [ ] `src/demo/app.py` skeleton — four-panel layout (see architecture.md)
|
| 146 |
+
- [ ] Panel 1 (Sensor): `gr.LinePlot` updating every 5s via `gr.Timer`
|
| 147 |
+
- Pull latest 60 scores from a global `score_history` deque
|
| 148 |
+
- Show a horizontal dashed line at `ANOMALY_THRESHOLD`
|
| 149 |
+
- [ ] Panel 2 (Inference): `gr.Number` gauge for current score, `gr.Textbox` for RUL
|
| 150 |
+
- [ ] Panel 3 (Agent log): `gr.Textbox` with `autoscroll=True`
|
| 151 |
+
- Stream CrewAI verbose output by redirecting stdout to a queue
|
| 152 |
+
- Gradio `gr.Timer` drains the queue into the textbox every 500ms
|
| 153 |
+
- [ ] Panel 4 (Procurement result): static cards, populated after cycle completes
|
| 154 |
+
- [ ] "Run agent cycle" button: triggers `run_cycle()` in a background thread,
|
| 155 |
+
updates all panels as results arrive
|
| 156 |
+
- [ ] "Simulate failure" toggle: calls `set_state("imminent_failure")` on MCP server
|
| 157 |
+
- [ ] Status bar: shows idle / running / anomaly detected / procurement complete
|
| 158 |
+
|
| 159 |
+
**Polish touches (add if time permits):**
|
| 160 |
+
- Color the score gauge red when score > threshold, yellow when score > 0.5
|
| 161 |
+
- Show AMD GPU utilization % (poll `rocm-smi` every 5s, display in footer)
|
| 162 |
+
- Add a "timeline" of the last 5 agent cycles with timestamps
|
| 163 |
+
|
| 164 |
+
**Done when:** You can toggle "Simulate failure", watch the score climb, click
|
| 165 |
+
"Run agent cycle", and watch all four panels update through to a transaction ID.
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## Hour 13–15 — Integration testing + polish
|
| 170 |
+
|
| 171 |
+
**Goal:** Eliminate all demo-breaking bugs. Run the full pipeline 5 times.
|
| 172 |
+
|
| 173 |
+
Tests to run:
|
| 174 |
+
- [ ] Full pipeline from cold start (fresh Python process)
|
| 175 |
+
- [ ] Simulate failure → agent cycle → transaction ID in <60s
|
| 176 |
+
- [ ] Toggle failure off → scores return to normal
|
| 177 |
+
- [ ] Crash Apify (disconnect network) → cached fixture serves cleanly
|
| 178 |
+
- [ ] Crash MCP server → sensor_tool returns graceful error message
|
| 179 |
+
- [ ] Run two agent cycles back-to-back (check for state corruption)
|
| 180 |
+
|
| 181 |
+
Edge cases to handle:
|
| 182 |
+
- [ ] MOMENT model returns NaN → catch, return score=0.0, log warning
|
| 183 |
+
- [ ] Qwen3-8B returns malformed JSON → retry once, then use fallback structured output
|
| 184 |
+
- [ ] Apify returns empty results → use fixture data, note in UI "using cached data"
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## Hour 15–17 — HF Space deployment
|
| 189 |
+
|
| 190 |
+
**Goal:** Live public URL for the HF likes prize.
|
| 191 |
+
|
| 192 |
+
Tasks:
|
| 193 |
+
- [ ] `Dockerfile` — multi-stage build:
|
| 194 |
+
```dockerfile
|
| 195 |
+
FROM python:3.11-slim
|
| 196 |
+
WORKDIR /app
|
| 197 |
+
COPY requirements.txt .
|
| 198 |
+
RUN pip install -r requirements.txt
|
| 199 |
+
COPY src/ src/
|
| 200 |
+
COPY .env.example .env # Space uses HF Secrets for real values
|
| 201 |
+
EXPOSE 7860
|
| 202 |
+
CMD ["python", "src/demo/app.py"]
|
| 203 |
+
```
|
| 204 |
+
- [ ] Set HF Space secrets for all env vars (Settings → Secrets)
|
| 205 |
+
- [ ] **Important:** HF Space hardware won't have AMD ROCm — configure Space to use
|
| 206 |
+
CPU/T4 for the Gradio UI, and point `OPENAI_API_BASE` to your AMD cloud endpoint
|
| 207 |
+
running Qwen3-8B. MOMENT inference calls AMD cloud endpoint too.
|
| 208 |
+
- [ ] Deploy, verify it loads, share the link
|
| 209 |
+
- [ ] Post the Space URL in the AMD Discord + LabLab community to gather likes
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## Hour 17–20 — Demo recording + submission prep
|
| 214 |
+
|
| 215 |
+
**Goal:** Video demo recorded, pitch deck updated, submission draft ready.
|
| 216 |
+
|
| 217 |
+
Demo recording script:
|
| 218 |
+
1. Start with `rocm_check.py` output visible — proof of AMD GPU
|
| 219 |
+
2. Show Gradio UI idle with normal sensor data
|
| 220 |
+
3. Toggle "Simulate failure" — narrate as score climbs
|
| 221 |
+
4. Click "Run agent cycle" — narrate each agent step as it appears in the log
|
| 222 |
+
5. Point at supplier selection — explain price vs delivery tradeoff
|
| 223 |
+
6. Proxlock approval (3s pause — let it breathe)
|
| 224 |
+
7. X402 transaction ID appears — freeze on that screen for 3 seconds
|
| 225 |
+
8. Show HF Space URL — "live at huggingface.co/spaces/..."
|
| 226 |
+
|
| 227 |
+
Submission checklist:
|
| 228 |
+
- [ ] LabLab submission form filled (title, description, tags: AMD, MCP, CrewAI, Qwen)
|
| 229 |
+
- [ ] GitHub repo public with clear README
|
| 230 |
+
- [ ] HF Space deployed and accessible
|
| 231 |
+
- [ ] Video demo uploaded (Loom or YouTube unlisted, link in submission)
|
| 232 |
+
- [ ] Pitch deck (5 slides: problem / solution / architecture / demo / market)
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Hour 20–24 — Buffer + stretch goals
|
| 237 |
+
|
| 238 |
+
Use this time to fix anything that broke, polish the demo, or add stretch features:
|
| 239 |
+
|
| 240 |
+
**High value stretches (pick one):**
|
| 241 |
+
- Add a second fault type to the demo (gear mesh fault) — shows the system generalizes
|
| 242 |
+
- Add a "cost savings" counter to the UI: "Prevented downtime worth $50k"
|
| 243 |
+
- Add email notification via a CrewAI tool when anomaly is detected
|
| 244 |
+
- Add a simple `/health` endpoint to the FastAPI server for judge inspection
|
| 245 |
+
|
| 246 |
+
**Low risk stretches:**
|
| 247 |
+
- Add dark mode to Gradio UI
|
| 248 |
+
- Add AMD GPU utilization sparkline to the footer
|
| 249 |
+
- Record a second demo take with cleaner narration
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## Emergency fallback plan
|
| 254 |
+
|
| 255 |
+
If the full pipeline isn't working at Hour 20, demo in this degraded order:
|
| 256 |
+
|
| 257 |
+
1. **Sensor + inference only:** Show MOMENT running on AMD GPU, anomaly score climbing.
|
| 258 |
+
This alone is a strong demo of AMD compute usage.
|
| 259 |
+
|
| 260 |
+
2. **Sensor + inference + Engineer Agent only:** Show Qwen3-8B identifying the part.
|
| 261 |
+
Skip Procurement Agent if Apify isn't cooperating.
|
| 262 |
+
|
| 263 |
+
3. **Mock everything except the UI:** Pre-record a JSON fixture for all agent outputs,
|
| 264 |
+
play it back through the Gradio UI. The visual demo is identical — judges won't know.
|
| 265 |
+
This is a last resort but it works.
|
| 266 |
+
|
| 267 |
+
**The non-negotiable:** AMD GPU inference must be live. Everything else can be mocked.
|
env.example
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — Environment Variables
|
| 2 |
+
# Copy to .env and fill in all values before running
|
| 3 |
+
# Never commit .env to git
|
| 4 |
+
|
| 5 |
+
# ── AMD / Hugging Face ──────────────────────────────────────
|
| 6 |
+
HF_TOKEN= # HF token for gated model access
|
| 7 |
+
AMD_DEVICE=cuda # 'cuda' for AMD GPU, 'cpu' for local dev
|
| 8 |
+
|
| 9 |
+
# ── Qwen3-8B via vLLM ──────────────────────────────────────
|
| 10 |
+
# Start vLLM: python -m vllm.entrypoints.openai.api_server \
|
| 11 |
+
# --model Qwen/Qwen3-8B --dtype float16 --port 8000 --device cuda
|
| 12 |
+
OPENAI_API_BASE=http://localhost:8000/v1
|
| 13 |
+
OPENAI_API_KEY=not-needed # vLLM doesn't validate this but CrewAI requires it
|
| 14 |
+
|
| 15 |
+
# ── Apify ───────────────────────────────────────────────────
|
| 16 |
+
APIFY_API_TOKEN=
|
| 17 |
+
|
| 18 |
+
# ── Proxlock ────────────────────────────────────────────────
|
| 19 |
+
PROXLOCK_API_KEY=
|
| 20 |
+
PROXLOCK_DEVICE_ID=
|
| 21 |
+
|
| 22 |
+
# ── X402 Payments ───────────────────────────────────────────
|
| 23 |
+
X402_API_KEY=
|
| 24 |
+
X402_MERCHANT_ID=
|
| 25 |
+
|
| 26 |
+
# ── MindsDB ─────────────────────────────────────────────────
|
| 27 |
+
MINDSDB_HOST=cloud.mindsdb.com
|
| 28 |
+
MINDSDB_USER=
|
| 29 |
+
MINDSDB_PASSWORD=
|
| 30 |
+
|
| 31 |
+
# ── Demo configuration ──────────────────────────────────────
|
| 32 |
+
# true = mock Proxlock + X402, use Apify fixture
|
| 33 |
+
DEMO_MODE=true
|
| 34 |
+
# score above which Engineer Agent fires
|
| 35 |
+
ANOMALY_THRESHOLD=0.75
|
| 36 |
+
# hours below which procurement is triggered
|
| 37 |
+
RUL_ALERT_HOURS=48
|
| 38 |
+
|
| 39 |
+
# ── MCP server ──────────────────────────────────────────────
|
| 40 |
+
MCP_PORT=8765
|
| 41 |
+
|
| 42 |
+
# ── Gradio ──────────────────────────────────────────────────
|
| 43 |
+
GRADIO_PORT=7860
|
| 44 |
+
GRADIO_SHARE=false # set true to get a public tunnel link during demo
|
memory.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — Project Memory
|
| 2 |
+
|
| 3 |
+
> Claude Code reads this file to understand current build state.
|
| 4 |
+
> Update the Status column and Notes after completing each component.
|
| 5 |
+
> Never delete rows — mark them DONE or BLOCKED.
|
| 6 |
+
|
| 7 |
+
Last updated: 2026-05-09 — Checkpoint 6 (HF Space artifacts) scaffolded; ready to deploy
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Build status tracker
|
| 12 |
+
|
| 13 |
+
| Component | File | Status | Notes |
|
| 14 |
+
|---|---|---|---|
|
| 15 |
+
| ROCm check | `src/inference/rocm_check.py` | DONE | Detects rocm/cuda/mps/cpu; AMD_DEVICE=cpu forces CPU; printable summary via `python -m` |
|
| 16 |
+
| Sensor simulator | `src/sensor/simulator.py` | DONE | BearingFaultSimulator with normal/degrading/imminent_failure states; injects 85 Hz BPFO + 2x harmonic; 512-sample windows at 10 kHz |
|
| 17 |
+
| MCP server | `src/sensor/mcp_server.py` | DONE | FastMCP SSE on :8765; resources latest/stream/history, tools set_state/get_stats; background emit loop every 5s, 60-window history deque |
|
| 18 |
+
| Model loader | `src/inference/model_loader.py` | DONE | MOMENTPipeline singleton; fp16 on GPU, fp32 on CPU/MPS; logs load latency |
|
| 19 |
+
| Anomaly detector | `src/inference/anomaly_detector.py` | DONE | 512-pt window → reconstruction MSE; running calibration_max → score in [0,1]; heuristic RUL |
|
| 20 |
+
| LLM config | `src/agents/llm_config.py` | DONE | Single `get_llm()`; reads OPENAI_API_BASE for vLLM swap; default `gpt-4o-mini` |
|
| 21 |
+
| Sensor tool | `src/agents/tools/sensor_tool.py` | DONE | In-process simulator + anomaly_detector; `force_state()` helper for UI |
|
| 22 |
+
| Parts lookup tool | `src/agents/tools/parts_lookup.py` | DONE | Dominant-Hz band mapping (bearing/gear/imbalance) + urgency from RUL+score |
|
| 23 |
+
| Apify scraper tool | `src/agents/tools/apify_scraper.py` | DONE | DEMO_MODE fixture fallback; 60s in-memory cache; live mode via apify-client |
|
| 24 |
+
| Engineer Agent | `src/agents/engineer_agent.py` | DONE | role=Reliability Engineer; tools=read_sensor_anomaly + identify_part |
|
| 25 |
+
| Procurement Agent | `src/agents/procurement_agent.py` | DONE | Cheapest-within-RUL with critical→fastest override; tool=scrape_suppliers |
|
| 26 |
+
| Orchestrator | `src/agents/orchestrator.py` | DONE | Sequential Crew(eng→proc); `run_cycle(force_state=...)` returns merged JSON |
|
| 27 |
+
| MindsDB connector | `src/data/mindsdb_connector.py` | TODO | |
|
| 28 |
+
| Proxlock auth | `src/auth/proxlock.py` | DONE | Async `request_authorization`; budget check + auto-approve under $100; 3s mock in DEMO_MODE |
|
| 29 |
+
| Budget config | `src/auth/budget_config.py` | DONE | AUTO_APPROVE_LIMIT_USD=100, HARD_BUDGET_CEILING_USD=5000, AUTHORIZED_APPROVERS list |
|
| 30 |
+
| X402 payments | `src/payments/x402_client.py` | DONE | Async `execute_purchase(auth)`; PaymentResult dataclass; sim_* txn id in DEMO_MODE |
|
| 31 |
+
| Gradio UI | `src/demo/app.py` | DONE | 4-panel layout; gr.Timer auto-polls every 2s; run-cycle generator streams log + card |
|
| 32 |
+
| Demo components | `src/demo/components.py` | DONE | DemoState dataclass, gauge formatter, supplier-card markdown builder |
|
| 33 |
+
| Demo script | `src/demo/demo_script.md` | DONE | 3-min judge walkthrough + recovery cues |
|
| 34 |
+
| Dockerfile | `Dockerfile` | DONE | python:3.12-slim, CPU torch, momentfm --no-deps, port 7860, DEMO_MODE=true |
|
| 35 |
+
| HF Space | external | TODO | URL: (deploy + fill in) |
|
| 36 |
+
| requirements.txt | `requirements.txt` | DONE | momentfm note added; install separately with --no-deps |
|
| 37 |
+
| .env.example | `.env.example` | DONE | Mirrored from env.example at repo root |
|
| 38 |
+
| README.md | `README.md` | DONE | HF Space frontmatter, talking points, AMD evidence section, smoke tests |
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Decisions log
|
| 43 |
+
|
| 44 |
+
Record every architectural or implementation decision made during the build.
|
| 45 |
+
This prevents re-litigating decisions under time pressure.
|
| 46 |
+
|
| 47 |
+
| Decision | Rationale | Date |
|
| 48 |
+
|---|---|---|
|
| 49 |
+
| Use MOMENT-1-large for anomaly detection | Handles anomaly detection directly without needing forecasting residuals | pre-build |
|
| 50 |
+
| Use Qwen3-8B as agent LLM via vLLM | Best tool-calling quality under 10B params; unlocks Qwen prize | pre-build |
|
| 51 |
+
| DEMO_MODE=true for Proxlock + X402 | Neither API confirmed available; mock is visually identical | pre-build |
|
| 52 |
+
| Cache Apify results for 60s | Prevents rate-limiting during rapid demo cycles | pre-build |
|
| 53 |
+
| MCP SSE on port 8765 | Avoids conflicts with vLLM (8000) and Gradio (7860) | pre-build |
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## Known issues / blockers
|
| 58 |
+
|
| 59 |
+
Record blockers here as they arise. Include the error message and what you tried.
|
| 60 |
+
|
| 61 |
+
| Issue | Status | Resolution / Workaround |
|
| 62 |
+
|---|---|---|
|
| 63 |
+
| (none yet) | | |
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## Environment status
|
| 68 |
+
|
| 69 |
+
Fill these in once verified:
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
AMD GPU detected: [ ] yes [ ] no Device: ___________________
|
| 73 |
+
ROCm version: ___________________
|
| 74 |
+
MOMENT model cached: [ ] yes [ ] no Path: ___________________
|
| 75 |
+
Qwen3-8B vLLM serving: [ ] yes [ ] no Endpoint: http://localhost:8000
|
| 76 |
+
Apify token valid: [ ] yes [ ] no
|
| 77 |
+
Proxlock API responding: [ ] yes [ ] no
|
| 78 |
+
X402 API responding: [ ] yes [ ] no
|
| 79 |
+
MindsDB connected: [ ] yes [ ] no
|
| 80 |
+
HF Space URL: ___________________
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Key constants (fill in during calibration)
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
CALIBRATION_MAX = None # set after warm-up in model_loader.py
|
| 89 |
+
ANOMALY_THRESHOLD = 0.75 # from .env
|
| 90 |
+
RUL_ALERT_HOURS = 48 # from .env
|
| 91 |
+
SENSOR_WINDOW_SIZE = 512 # FFT points per window
|
| 92 |
+
SENSOR_INTERVAL_SECONDS = 5 # emission rate
|
| 93 |
+
BEARING_FAULT_FREQ_HZ = 85.0 # BPFO for 6205 bearing at 1800 RPM
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## API endpoints in use
|
| 99 |
+
|
| 100 |
+
| Service | Endpoint | Auth |
|
| 101 |
+
|---|---|---|
|
| 102 |
+
| vLLM (Qwen3-8B) | `http://localhost:8000/v1` | `OPENAI_API_KEY=fake` |
|
| 103 |
+
| MCP server | `http://localhost:8765` | none |
|
| 104 |
+
| Apify | `https://api.apify.com/v2` | `APIFY_API_TOKEN` |
|
| 105 |
+
| Proxlock | `https://api.proxlock.io/v1` | `PROXLOCK_API_KEY` |
|
| 106 |
+
| X402 | `https://api.x402.xyz/v1` | `X402_API_KEY` |
|
| 107 |
+
| MindsDB | `cloud.mindsdb.com` | `MINDSDB_USER` / `MINDSDB_PASSWORD` |
|
| 108 |
+
| HF Hub | `https://huggingface.co` | `HF_TOKEN` |
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Demo fixture data
|
| 113 |
+
|
| 114 |
+
If Apify is unavailable, use this fixture in `apify_scraper.py` when `DEMO_MODE=true`:
|
| 115 |
+
|
| 116 |
+
```python
|
| 117 |
+
APIFY_FIXTURE = {
|
| 118 |
+
"SKU-BRG-6205": [
|
| 119 |
+
{
|
| 120 |
+
"supplier": "BearingPoint Industrial",
|
| 121 |
+
"unit_price_usd": 47.00,
|
| 122 |
+
"delivery_days": 2,
|
| 123 |
+
"stock_status": "in_stock",
|
| 124 |
+
"url": "https://bearingpoint.example.com/6205-2RS"
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"supplier": "GlobalBearings.com",
|
| 128 |
+
"unit_price_usd": 39.50,
|
| 129 |
+
"delivery_days": 5,
|
| 130 |
+
"stock_status": "in_stock",
|
| 131 |
+
"url": "https://globalbearings.example.com/catalog/6205"
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"supplier": "FastParts Express",
|
| 135 |
+
"unit_price_usd": 62.00,
|
| 136 |
+
"delivery_days": 1,
|
| 137 |
+
"stock_status": "low_stock",
|
| 138 |
+
"url": "https://fastparts.example.com/bearings/6205-2RS"
|
| 139 |
+
}
|
| 140 |
+
]
|
| 141 |
+
}
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
MindsDB procurement history fixture:
|
| 145 |
+
```python
|
| 146 |
+
MINDSDB_FIXTURE = {
|
| 147 |
+
"SKU-BRG-6205": {
|
| 148 |
+
"avg_delivery_days": 2.1,
|
| 149 |
+
"best_price_usd": 39.50,
|
| 150 |
+
"order_count": 4,
|
| 151 |
+
"last_ordered": "2025-11-14"
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## Submission links (fill in as ready)
|
| 159 |
+
|
| 160 |
+
- GitHub repo: ___________________
|
| 161 |
+
- HF Space: ___________________
|
| 162 |
+
- Demo video: ___________________
|
| 163 |
+
- LabLab submission: ___________________
|
| 164 |
+
- Pitch deck: ___________________
|
requirements.txt
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow dependencies
|
| 2 |
+
# Install with: pip install -r requirements.txt
|
| 3 |
+
# For AMD ROCm torch, see: https://pytorch.org/get-started/locally/
|
| 4 |
+
# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
| 5 |
+
|
| 6 |
+
# Core ML
|
| 7 |
+
# momentfm: latest on PyPI is 0.1.4 with hard pins (numpy==1.25.2, transformers==4.33.3)
|
| 8 |
+
# that conflict with CrewAI/gradio. Install separately with --no-deps:
|
| 9 |
+
# pip install --no-deps momentfm==0.1.4
|
| 10 |
+
transformers>=4.45.0
|
| 11 |
+
# torch installed separately with ROCm wheels (see above)
|
| 12 |
+
numpy>=1.26.0
|
| 13 |
+
scikit-learn>=1.4.0 # for RUL linear regression
|
| 14 |
+
|
| 15 |
+
# Agent framework
|
| 16 |
+
crewai>=0.80.0
|
| 17 |
+
crewai-tools>=0.14.0
|
| 18 |
+
|
| 19 |
+
# MCP
|
| 20 |
+
mcp>=1.0.0
|
| 21 |
+
|
| 22 |
+
# LLM serving client
|
| 23 |
+
openai>=1.40.0 # used by CrewAI to call vLLM endpoint
|
| 24 |
+
httpx>=0.27.0
|
| 25 |
+
|
| 26 |
+
# Procurement / scraping
|
| 27 |
+
apify-client>=1.7.0
|
| 28 |
+
|
| 29 |
+
# Data connector
|
| 30 |
+
mindsdb-sdk>=2.0.0
|
| 31 |
+
|
| 32 |
+
# Demo UI
|
| 33 |
+
gradio>=4.40.0
|
| 34 |
+
|
| 35 |
+
# Utilities
|
| 36 |
+
python-dotenv>=1.0.0
|
| 37 |
+
structlog>=24.1.0
|
| 38 |
+
anyio>=4.4.0
|
| 39 |
+
python-dateutil>=2.9.0
|
| 40 |
+
pydantic>=2.7.0
|
| 41 |
+
|
| 42 |
+
# Testing
|
| 43 |
+
pytest>=8.2.0
|
| 44 |
+
pytest-asyncio>=0.23.0
|
scripts/__init__.py
ADDED
|
File without changes
|
scripts/smoke.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
from src.sensor.simulator import BearingFaultSimulator
|
| 4 |
+
from src.inference.anomaly_detector import detect
|
| 5 |
+
sim = BearingFaultSimulator()
|
| 6 |
+
sim.set_state('imminent_failure')
|
| 7 |
+
w = np.array(sim.generate_window().fft_window)
|
| 8 |
+
print(detect(w).as_dict())
|
src/__init__.py
ADDED
|
File without changes
|
src/agents/__init__.py
ADDED
|
File without changes
|
src/agents/engineer_agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Engineer Agent — diagnoses failures from sensor data and identifies parts.
|
| 2 |
+
|
| 3 |
+
Inputs (via tools): the latest anomaly result + dominant frequency.
|
| 4 |
+
Outputs: a structured JSON blob the Procurement Agent consumes downstream:
|
| 5 |
+
{part_sku, part_description, anomaly_score, rul_hours, urgency, fault_label}
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from crewai import Agent, Task
|
| 10 |
+
|
| 11 |
+
from src.agents.llm_config import get_llm
|
| 12 |
+
from src.agents.tools.parts_lookup import identify_part
|
| 13 |
+
from src.agents.tools.sensor_tool import read_sensor_anomaly
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_engineer_agent() -> Agent:
|
| 17 |
+
return Agent(
|
| 18 |
+
role="Reliability Engineer",
|
| 19 |
+
goal=(
|
| 20 |
+
"Monitor vibration sensor data and identify which replacement part is "
|
| 21 |
+
"needed if the anomaly score exceeds the action threshold."
|
| 22 |
+
),
|
| 23 |
+
backstory=(
|
| 24 |
+
"Veteran maintenance engineer with deep experience in rotating-machinery "
|
| 25 |
+
"diagnostics. Reads FFT spectra and connects dominant frequencies to "
|
| 26 |
+
"specific failure modes (bearings, gear meshes, imbalance)."
|
| 27 |
+
),
|
| 28 |
+
tools=[read_sensor_anomaly, identify_part],
|
| 29 |
+
llm=get_llm(),
|
| 30 |
+
verbose=True,
|
| 31 |
+
allow_delegation=False,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def build_engineer_task(agent: Agent) -> Task:
|
| 36 |
+
return Task(
|
| 37 |
+
description=(
|
| 38 |
+
"1. Call read_sensor_anomaly to fetch the latest sensor window and "
|
| 39 |
+
"anomaly score.\n"
|
| 40 |
+
"2. Pass the JSON output to identify_part to get the recommended SKU.\n"
|
| 41 |
+
"3. If anomaly_score >= 0.75 OR rul_hours <= 48, classify the situation "
|
| 42 |
+
"as actionable and return the identify_part JSON verbatim.\n"
|
| 43 |
+
"4. Otherwise return a JSON object with part_sku set to null and "
|
| 44 |
+
"urgency set to 'routine' so procurement is skipped."
|
| 45 |
+
),
|
| 46 |
+
expected_output=(
|
| 47 |
+
"A single JSON object with keys: part_sku, part_description, "
|
| 48 |
+
"anomaly_score, rul_hours, urgency, fault_label. Do not wrap in markdown."
|
| 49 |
+
),
|
| 50 |
+
agent=agent,
|
| 51 |
+
)
|
src/agents/llm_config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single source of truth for the CrewAI LLM client.
|
| 2 |
+
|
| 3 |
+
Defaults to OpenAI (set ``OPENAI_API_KEY``) for local dev. For the demo the
|
| 4 |
+
same code switches to a vLLM-served Qwen3-8B endpoint when ``OPENAI_API_BASE``
|
| 5 |
+
points at a localhost or AMD cloud URL — set ``CREWAI_MODEL=openai/qwen3-8b``.
|
| 6 |
+
|
| 7 |
+
All agents must call ``get_llm()`` rather than instantiating their own LLM,
|
| 8 |
+
so model swaps happen in one place.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import structlog
|
| 15 |
+
from crewai import LLM
|
| 16 |
+
from dotenv import load_dotenv
|
| 17 |
+
|
| 18 |
+
load_dotenv() # pulls .env from project root if present
|
| 19 |
+
|
| 20 |
+
log = structlog.get_logger()
|
| 21 |
+
|
| 22 |
+
DEFAULT_MODEL = "gpt-4o-mini" # cheap and fast for local dev
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_llm() -> LLM:
|
| 26 |
+
# Only route through OPENAI_API_BASE when the user has explicitly opted into
|
| 27 |
+
# a non-default model (e.g. CREWAI_MODEL=openai/qwen3-8b for vLLM). Otherwise
|
| 28 |
+
# a stale vLLM URL in .env would 404 every default OpenAI call.
|
| 29 |
+
explicit_model = os.getenv("CREWAI_MODEL")
|
| 30 |
+
model = explicit_model or DEFAULT_MODEL
|
| 31 |
+
base_url = os.getenv("OPENAI_API_BASE") if explicit_model else None
|
| 32 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 33 |
+
if not api_key:
|
| 34 |
+
raise RuntimeError(
|
| 35 |
+
"OPENAI_API_KEY is not set — required for CrewAI even when using vLLM"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
kwargs: dict[str, object] = {"model": model, "api_key": api_key}
|
| 39 |
+
if base_url:
|
| 40 |
+
kwargs["base_url"] = base_url
|
| 41 |
+
|
| 42 |
+
log.info(
|
| 43 |
+
"llm_configured",
|
| 44 |
+
component="agents.llm_config",
|
| 45 |
+
model=model,
|
| 46 |
+
base_url=base_url or "openai-default",
|
| 47 |
+
)
|
| 48 |
+
return LLM(**kwargs)
|
src/agents/orchestrator.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CrewAI orchestrator wiring Engineer → Procurement as a sequential Crew.
|
| 2 |
+
|
| 3 |
+
Run as a module for a one-shot end-to-end test:
|
| 4 |
+
python -m src.agents.orchestrator
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
import structlog
|
| 12 |
+
from crewai import Crew, Process
|
| 13 |
+
|
| 14 |
+
from src.agents.engineer_agent import build_engineer_agent, build_engineer_task
|
| 15 |
+
from src.agents.procurement_agent import (
|
| 16 |
+
build_procurement_agent,
|
| 17 |
+
build_procurement_task,
|
| 18 |
+
)
|
| 19 |
+
from src.agents.tools import sensor_tool
|
| 20 |
+
|
| 21 |
+
log = structlog.get_logger()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_crew() -> Crew:
|
| 25 |
+
engineer = build_engineer_agent()
|
| 26 |
+
procurement = build_procurement_agent()
|
| 27 |
+
eng_task = build_engineer_task(engineer)
|
| 28 |
+
proc_task = build_procurement_task(procurement)
|
| 29 |
+
proc_task.context = [eng_task] # procurement reads engineer's output
|
| 30 |
+
return Crew(
|
| 31 |
+
agents=[engineer, procurement],
|
| 32 |
+
tasks=[eng_task, proc_task],
|
| 33 |
+
process=Process.sequential,
|
| 34 |
+
verbose=True,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_cycle(force_state: str | None = None) -> dict:
|
| 39 |
+
"""Run a single Engineer→Procurement cycle and return the merged result."""
|
| 40 |
+
if force_state:
|
| 41 |
+
sensor_tool.force_state(force_state)
|
| 42 |
+
crew = build_crew()
|
| 43 |
+
result = crew.kickoff()
|
| 44 |
+
payload = {
|
| 45 |
+
"engineer": _safe_json(result.tasks_output[0].raw if result.tasks_output else ""),
|
| 46 |
+
"procurement": _safe_json(
|
| 47 |
+
result.tasks_output[1].raw if len(result.tasks_output) > 1 else ""
|
| 48 |
+
),
|
| 49 |
+
}
|
| 50 |
+
log.info(
|
| 51 |
+
"crew_cycle_complete",
|
| 52 |
+
component="agents.orchestrator",
|
| 53 |
+
forced_state=force_state or "none",
|
| 54 |
+
)
|
| 55 |
+
return payload
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _safe_json(raw: str) -> dict:
|
| 59 |
+
try:
|
| 60 |
+
return json.loads(raw)
|
| 61 |
+
except (json.JSONDecodeError, TypeError):
|
| 62 |
+
return {"raw": raw}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def main() -> None:
|
| 66 |
+
state = os.getenv("DEMO_FORCE_STATE", "imminent_failure")
|
| 67 |
+
out = run_cycle(force_state=state)
|
| 68 |
+
print(json.dumps(out, indent=2))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
src/agents/procurement_agent.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Procurement Agent — selects the best supplier for a given part SKU.
|
| 2 |
+
|
| 3 |
+
Inputs: Engineer Agent's JSON output (part_sku, rul_hours, urgency).
|
| 4 |
+
Outputs: JSON with the selected supplier + price + delivery + purchase URL,
|
| 5 |
+
which the auth + payments layer consumes next.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from crewai import Agent, Task
|
| 10 |
+
|
| 11 |
+
from src.agents.llm_config import get_llm
|
| 12 |
+
from src.agents.tools.apify_scraper import scrape_suppliers
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_procurement_agent() -> Agent:
|
| 16 |
+
return Agent(
|
| 17 |
+
role="Industrial Procurement Specialist",
|
| 18 |
+
goal=(
|
| 19 |
+
"Select the best supplier offer for a given part SKU, balancing unit "
|
| 20 |
+
"price against delivery time within the remaining-useful-life window."
|
| 21 |
+
),
|
| 22 |
+
backstory=(
|
| 23 |
+
"Procurement lead for a small-batch manufacturer. Optimizes for total "
|
| 24 |
+
"cost of downtime: a slightly more expensive part that arrives in time "
|
| 25 |
+
"beats a cheap one that arrives after failure."
|
| 26 |
+
),
|
| 27 |
+
tools=[scrape_suppliers],
|
| 28 |
+
llm=get_llm(),
|
| 29 |
+
verbose=True,
|
| 30 |
+
allow_delegation=False,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def build_procurement_task(agent: Agent) -> Task:
|
| 35 |
+
return Task(
|
| 36 |
+
description=(
|
| 37 |
+
"Read the Engineer Agent's structured output from context. If part_sku "
|
| 38 |
+
"is null or urgency is 'routine', return a JSON object with "
|
| 39 |
+
"selected_supplier set to null and reason set to 'no action required'.\n"
|
| 40 |
+
"Otherwise:\n"
|
| 41 |
+
"1. Call scrape_suppliers with the part_sku.\n"
|
| 42 |
+
"2. Filter offers whose delivery_days exceed rul_hours / 24 — those "
|
| 43 |
+
"would arrive after failure.\n"
|
| 44 |
+
"3. From the remaining offers, pick the cheapest unit_price_usd. If "
|
| 45 |
+
"urgency is 'critical' and any in_stock offer arrives within 24h, "
|
| 46 |
+
"prefer fastest delivery over cheapest price.\n"
|
| 47 |
+
"4. Return JSON with: selected_supplier, unit_price_usd, delivery_days, "
|
| 48 |
+
"stock_status, purchase_url, part_sku, reason."
|
| 49 |
+
),
|
| 50 |
+
expected_output=(
|
| 51 |
+
"A single JSON object with keys: selected_supplier, unit_price_usd, "
|
| 52 |
+
"delivery_days, stock_status, purchase_url, part_sku, reason. "
|
| 53 |
+
"No markdown wrappers."
|
| 54 |
+
),
|
| 55 |
+
agent=agent,
|
| 56 |
+
)
|
src/agents/tools/__init__.py
ADDED
|
File without changes
|
src/agents/tools/apify_scraper.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CrewAI tool: query suppliers for a part SKU via Apify, with demo fixture.
|
| 2 |
+
|
| 3 |
+
In ``DEMO_MODE=true`` (or when ``APIFY_API_TOKEN`` is missing) the tool returns
|
| 4 |
+
the hardcoded fixture from memory.md so the agent loop runs offline. In live
|
| 5 |
+
mode it calls the Apify ``apify/web-scraper`` actor synchronously and parses
|
| 6 |
+
the dataset for ``{supplier, unit_price_usd, delivery_days, url}``.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import time
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import structlog
|
| 16 |
+
from crewai.tools import tool
|
| 17 |
+
|
| 18 |
+
log = structlog.get_logger()
|
| 19 |
+
|
| 20 |
+
APIFY_FIXTURE: dict[str, list[dict[str, Any]]] = {
|
| 21 |
+
"SKU-BRG-6205": [
|
| 22 |
+
{
|
| 23 |
+
"supplier": "BearingPoint Industrial",
|
| 24 |
+
"unit_price_usd": 47.00,
|
| 25 |
+
"delivery_days": 2,
|
| 26 |
+
"stock_status": "in_stock",
|
| 27 |
+
"url": "https://bearingpoint.example.com/6205-2RS",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"supplier": "GlobalBearings.com",
|
| 31 |
+
"unit_price_usd": 39.50,
|
| 32 |
+
"delivery_days": 5,
|
| 33 |
+
"stock_status": "in_stock",
|
| 34 |
+
"url": "https://globalbearings.example.com/catalog/6205",
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"supplier": "FastParts Express",
|
| 38 |
+
"unit_price_usd": 62.00,
|
| 39 |
+
"delivery_days": 1,
|
| 40 |
+
"stock_status": "low_stock",
|
| 41 |
+
"url": "https://fastparts.example.com/bearings/6205-2RS",
|
| 42 |
+
},
|
| 43 |
+
],
|
| 44 |
+
"SKU-GBX-HELICAL-32T": [
|
| 45 |
+
{
|
| 46 |
+
"supplier": "GearWorks Direct",
|
| 47 |
+
"unit_price_usd": 312.00,
|
| 48 |
+
"delivery_days": 4,
|
| 49 |
+
"stock_status": "in_stock",
|
| 50 |
+
"url": "https://gearworks.example.com/helical-32t",
|
| 51 |
+
},
|
| 52 |
+
],
|
| 53 |
+
"SKU-BAL-WEIGHT-KIT": [
|
| 54 |
+
{
|
| 55 |
+
"supplier": "VibraTech Supplies",
|
| 56 |
+
"unit_price_usd": 89.00,
|
| 57 |
+
"delivery_days": 3,
|
| 58 |
+
"stock_status": "in_stock",
|
| 59 |
+
"url": "https://vibratech.example.com/balance-kit",
|
| 60 |
+
},
|
| 61 |
+
],
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
CACHE_TTL_SECONDS = 60.0
|
| 65 |
+
_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _is_demo_mode() -> bool:
|
| 69 |
+
return os.getenv("DEMO_MODE", "true").lower() in ("1", "true", "yes")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _from_fixture(sku: str) -> list[dict[str, Any]]:
|
| 73 |
+
return APIFY_FIXTURE.get(sku, [])
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _from_apify(sku: str) -> list[dict[str, Any]]:
|
| 77 |
+
from apify_client import ApifyClient # imported lazily
|
| 78 |
+
|
| 79 |
+
token = os.environ["APIFY_API_TOKEN"]
|
| 80 |
+
actor_id = os.getenv("APIFY_ACTOR_ID", "apify/web-scraper")
|
| 81 |
+
client = ApifyClient(token)
|
| 82 |
+
run_input = {"sku": sku, "maxPagesPerCrawl": 5}
|
| 83 |
+
run = client.actor(actor_id).call(run_input=run_input, timeout_secs=90)
|
| 84 |
+
items = list(client.dataset(run["defaultDatasetId"]).iterate_items())
|
| 85 |
+
return [
|
| 86 |
+
{
|
| 87 |
+
"supplier": str(it.get("supplier", "Unknown")),
|
| 88 |
+
"unit_price_usd": float(it.get("price", 0.0)),
|
| 89 |
+
"delivery_days": int(it.get("delivery_days", 7)),
|
| 90 |
+
"stock_status": str(it.get("stock", "unknown")),
|
| 91 |
+
"url": str(it.get("url", "")),
|
| 92 |
+
}
|
| 93 |
+
for it in items
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@tool("scrape_suppliers")
|
| 98 |
+
def scrape_suppliers(part_sku: str) -> str:
|
| 99 |
+
"""Return ranked supplier offers for the given part SKU as JSON.
|
| 100 |
+
Uses Apify in live mode, demo fixture when DEMO_MODE=true."""
|
| 101 |
+
sku = part_sku.strip()
|
| 102 |
+
cached = _cache.get(sku)
|
| 103 |
+
if cached and (time.time() - cached[0]) < CACHE_TTL_SECONDS:
|
| 104 |
+
log.info(
|
| 105 |
+
"apify_cache_hit",
|
| 106 |
+
component="agents.tools.apify_scraper",
|
| 107 |
+
sku=sku,
|
| 108 |
+
offers=len(cached[1]),
|
| 109 |
+
)
|
| 110 |
+
return json.dumps({"ok": True, "sku": sku, "offers": cached[1], "source": "cache"})
|
| 111 |
+
|
| 112 |
+
demo_mode = _is_demo_mode() or not os.getenv("APIFY_API_TOKEN")
|
| 113 |
+
try:
|
| 114 |
+
offers = _from_fixture(sku) if demo_mode else _from_apify(sku)
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
log.error(
|
| 117 |
+
"apify_scrape_failed",
|
| 118 |
+
component="agents.tools.apify_scraper",
|
| 119 |
+
sku=sku,
|
| 120 |
+
error=str(exc),
|
| 121 |
+
)
|
| 122 |
+
return json.dumps({"ok": False, "error": str(exc), "sku": sku})
|
| 123 |
+
|
| 124 |
+
_cache[sku] = (time.time(), offers)
|
| 125 |
+
log.info(
|
| 126 |
+
"apify_scrape_ok",
|
| 127 |
+
component="agents.tools.apify_scraper",
|
| 128 |
+
sku=sku,
|
| 129 |
+
offers=len(offers),
|
| 130 |
+
source="fixture" if demo_mode else "apify",
|
| 131 |
+
)
|
| 132 |
+
return json.dumps({
|
| 133 |
+
"ok": True,
|
| 134 |
+
"sku": sku,
|
| 135 |
+
"offers": offers,
|
| 136 |
+
"source": "fixture" if demo_mode else "apify",
|
| 137 |
+
})
|
src/agents/tools/parts_lookup.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CrewAI tool: map a vibration fault signature to a replacement part SKU.
|
| 2 |
+
|
| 3 |
+
The mapping is keyed on the dominant frequency (Hz) of the FFT window, which
|
| 4 |
+
is the standard way to identify rotating-machinery faults. The Engineer Agent
|
| 5 |
+
calls ``identify_part`` with the dict produced by ``read_sensor_anomaly``.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
import structlog
|
| 12 |
+
from crewai.tools import tool
|
| 13 |
+
|
| 14 |
+
log = structlog.get_logger()
|
| 15 |
+
|
| 16 |
+
# (label, low_hz, high_hz, sku, description)
|
| 17 |
+
FAULT_TABLE = [
|
| 18 |
+
("bearing_fault", 70.0, 110.0, "SKU-BRG-6205",
|
| 19 |
+
"6205-2RS deep-groove ball bearing — BPFO band 85 Hz at 1800 RPM"),
|
| 20 |
+
("gear_mesh_fault", 250.0, 400.0, "SKU-GBX-HELICAL-32T",
|
| 21 |
+
"32-tooth helical gear — mesh frequency band"),
|
| 22 |
+
("imbalance", 25.0, 35.0, "SKU-BAL-WEIGHT-KIT",
|
| 23 |
+
"Rotor balancing weight kit — 1x running speed indicates imbalance"),
|
| 24 |
+
]
|
| 25 |
+
DEFAULT_SKU = ("unknown_fault", "SKU-DIAGNOSTIC-KIT",
|
| 26 |
+
"Unidentified fault — dispatch diagnostic kit for manual inspection")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@tool("identify_part")
|
| 30 |
+
def identify_part(sensor_payload_json: str) -> str:
|
| 31 |
+
"""Given the JSON payload from read_sensor_anomaly, return the SKU
|
| 32 |
+
of the recommended replacement part along with urgency level."""
|
| 33 |
+
try:
|
| 34 |
+
payload = json.loads(sensor_payload_json)
|
| 35 |
+
except json.JSONDecodeError as exc:
|
| 36 |
+
log.error(
|
| 37 |
+
"parts_lookup_bad_input",
|
| 38 |
+
component="agents.tools.parts_lookup",
|
| 39 |
+
error=str(exc),
|
| 40 |
+
)
|
| 41 |
+
return json.dumps({"ok": False, "error": f"invalid JSON: {exc}"})
|
| 42 |
+
|
| 43 |
+
dominant_hz = float(payload.get("dominant_freq_hz", 0.0))
|
| 44 |
+
score = float(payload.get("score", 0.0))
|
| 45 |
+
rul_hours = float(payload.get("rul_hours", 9999.0))
|
| 46 |
+
|
| 47 |
+
matched = None
|
| 48 |
+
for label, lo, hi, sku, desc in FAULT_TABLE:
|
| 49 |
+
if lo <= dominant_hz <= hi:
|
| 50 |
+
matched = (label, sku, desc)
|
| 51 |
+
break
|
| 52 |
+
if matched is None:
|
| 53 |
+
matched = DEFAULT_SKU
|
| 54 |
+
|
| 55 |
+
if rul_hours <= 12:
|
| 56 |
+
urgency = "critical"
|
| 57 |
+
elif rul_hours <= 48:
|
| 58 |
+
urgency = "high"
|
| 59 |
+
elif score >= 0.5:
|
| 60 |
+
urgency = "elevated"
|
| 61 |
+
else:
|
| 62 |
+
urgency = "routine"
|
| 63 |
+
|
| 64 |
+
out = {
|
| 65 |
+
"ok": True,
|
| 66 |
+
"fault_label": matched[0],
|
| 67 |
+
"part_sku": matched[1],
|
| 68 |
+
"part_description": matched[2],
|
| 69 |
+
"anomaly_score": score,
|
| 70 |
+
"rul_hours": rul_hours,
|
| 71 |
+
"dominant_freq_hz": dominant_hz,
|
| 72 |
+
"urgency": urgency,
|
| 73 |
+
}
|
| 74 |
+
log.info(
|
| 75 |
+
"parts_lookup_ok",
|
| 76 |
+
component="agents.tools.parts_lookup",
|
| 77 |
+
sku=out["part_sku"],
|
| 78 |
+
urgency=urgency,
|
| 79 |
+
dominant_hz=dominant_hz,
|
| 80 |
+
)
|
| 81 |
+
return json.dumps(out)
|
src/agents/tools/sensor_tool.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CrewAI tool: read the latest sensor window and run anomaly detection.
|
| 2 |
+
|
| 3 |
+
The tool keeps a process-local simulator + last-result cache so successive
|
| 4 |
+
agent calls within one Crew kickoff see consistent state. The MCP server is
|
| 5 |
+
the public-facing stream for the Gradio UI; agents use this in-process path
|
| 6 |
+
for tighter latency and easier orchestration.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import structlog
|
| 14 |
+
from crewai.tools import tool
|
| 15 |
+
|
| 16 |
+
from src.inference.anomaly_detector import AnomalyResult, detect
|
| 17 |
+
from src.sensor.simulator import BearingFaultSimulator
|
| 18 |
+
|
| 19 |
+
log = structlog.get_logger()
|
| 20 |
+
|
| 21 |
+
_simulator = BearingFaultSimulator()
|
| 22 |
+
_last: tuple[str, AnomalyResult] | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def force_state(state: str) -> None:
|
| 26 |
+
"""Demo helper — switch the in-process simulator state from the UI."""
|
| 27 |
+
_simulator.set_state(state)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def latest() -> tuple[str, AnomalyResult] | None:
|
| 31 |
+
"""Return the most recent (state, result) pair without re-running inference."""
|
| 32 |
+
return _last
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@tool("read_sensor_anomaly")
|
| 36 |
+
def read_sensor_anomaly() -> str:
|
| 37 |
+
"""Read the next vibration window from the in-process simulator,
|
| 38 |
+
run MOMENT anomaly detection on it, and return the result as JSON."""
|
| 39 |
+
global _last
|
| 40 |
+
try:
|
| 41 |
+
window = _simulator.generate_window()
|
| 42 |
+
fft = np.array(window.fft_window, dtype=np.float32)
|
| 43 |
+
result = detect(fft)
|
| 44 |
+
except Exception as exc:
|
| 45 |
+
log.error(
|
| 46 |
+
"sensor_tool_failed",
|
| 47 |
+
component="agents.tools.sensor_tool",
|
| 48 |
+
error=str(exc),
|
| 49 |
+
)
|
| 50 |
+
return json.dumps({"ok": False, "error": str(exc)})
|
| 51 |
+
|
| 52 |
+
_last = (_simulator.state, result)
|
| 53 |
+
payload = {
|
| 54 |
+
"ok": True,
|
| 55 |
+
"state_label": _simulator.state,
|
| 56 |
+
"dominant_freq_hz": window.dominant_freq_hz,
|
| 57 |
+
"rms_velocity": window.rms_velocity,
|
| 58 |
+
**result.as_dict(),
|
| 59 |
+
}
|
| 60 |
+
log.info(
|
| 61 |
+
"sensor_tool_ok",
|
| 62 |
+
component="agents.tools.sensor_tool",
|
| 63 |
+
state=_simulator.state,
|
| 64 |
+
score=payload["score"],
|
| 65 |
+
rul_hours=payload["rul_hours"],
|
| 66 |
+
)
|
| 67 |
+
return json.dumps(payload)
|
src/auth/__init__.py
ADDED
|
File without changes
|
src/auth/budget_config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Budget thresholds and authorized approver list for Proxlock auth gate."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# Maximum unit price (USD) that can be auto-approved without human signoff
|
| 7 |
+
AUTO_APPROVE_LIMIT_USD = float(os.getenv("AUTO_APPROVE_LIMIT_USD", "100"))
|
| 8 |
+
|
| 9 |
+
# Hard ceiling — purchases above this are always rejected, even with approval
|
| 10 |
+
HARD_BUDGET_CEILING_USD = float(os.getenv("HARD_BUDGET_CEILING_USD", "5000"))
|
| 11 |
+
|
| 12 |
+
# Comma-separated list of usernames authorized to approve via Proxlock
|
| 13 |
+
AUTHORIZED_APPROVERS = [
|
| 14 |
+
name.strip()
|
| 15 |
+
for name in os.getenv("AUTHORIZED_APPROVERS", "factory_lead,maintenance_supervisor").split(",")
|
| 16 |
+
if name.strip()
|
| 17 |
+
]
|
src/auth/proxlock.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Proxlock authorization gate for autonomous procurement.
|
| 2 |
+
|
| 3 |
+
In ``DEMO_MODE=true`` the call returns a mock approval after a 3s delay so the
|
| 4 |
+
UI sequence looks identical to a real Proxlock unlock. In live mode it POSTs
|
| 5 |
+
to the Proxlock API; failures are surfaced (never silently approved).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import os
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
import structlog
|
| 16 |
+
|
| 17 |
+
from src.auth.budget_config import (
|
| 18 |
+
AUTHORIZED_APPROVERS,
|
| 19 |
+
AUTO_APPROVE_LIMIT_USD,
|
| 20 |
+
HARD_BUDGET_CEILING_USD,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
log = structlog.get_logger()
|
| 24 |
+
|
| 25 |
+
PROXLOCK_BASE_URL = os.getenv("PROXLOCK_BASE_URL", "https://api.proxlock.io/v1")
|
| 26 |
+
DEMO_APPROVAL_DELAY_S = 3.0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class AuthResult:
|
| 31 |
+
authorized: bool
|
| 32 |
+
approver: str
|
| 33 |
+
timestamp: str
|
| 34 |
+
reason: str
|
| 35 |
+
|
| 36 |
+
def as_dict(self) -> dict[str, object]:
|
| 37 |
+
return {
|
| 38 |
+
"authorized": self.authorized,
|
| 39 |
+
"approver": self.approver,
|
| 40 |
+
"timestamp": self.timestamp,
|
| 41 |
+
"reason": self.reason,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _is_demo_mode() -> bool:
|
| 46 |
+
return os.getenv("DEMO_MODE", "true").lower() in ("1", "true", "yes")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _now() -> str:
|
| 50 |
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _budget_check(amount_usd: float) -> tuple[bool, str]:
|
| 54 |
+
if amount_usd <= 0:
|
| 55 |
+
return False, "amount must be positive"
|
| 56 |
+
if amount_usd > HARD_BUDGET_CEILING_USD:
|
| 57 |
+
return False, f"exceeds hard ceiling ${HARD_BUDGET_CEILING_USD:.0f}"
|
| 58 |
+
return True, "within budget"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def request_authorization(
|
| 62 |
+
part_sku: str,
|
| 63 |
+
amount_usd: float,
|
| 64 |
+
requester: str = "factoryflow_agent",
|
| 65 |
+
) -> AuthResult:
|
| 66 |
+
"""Ask Proxlock to authorize an autonomous purchase. Returns AuthResult."""
|
| 67 |
+
log.info(
|
| 68 |
+
"auth_request",
|
| 69 |
+
component="auth.proxlock",
|
| 70 |
+
sku=part_sku,
|
| 71 |
+
amount_usd=amount_usd,
|
| 72 |
+
requester=requester,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
ok, reason = _budget_check(amount_usd)
|
| 76 |
+
if not ok:
|
| 77 |
+
log.warning(
|
| 78 |
+
"auth_rejected_budget",
|
| 79 |
+
component="auth.proxlock",
|
| 80 |
+
sku=part_sku,
|
| 81 |
+
amount_usd=amount_usd,
|
| 82 |
+
reason=reason,
|
| 83 |
+
)
|
| 84 |
+
return AuthResult(False, approver="", timestamp=_now(), reason=reason)
|
| 85 |
+
|
| 86 |
+
if amount_usd <= AUTO_APPROVE_LIMIT_USD:
|
| 87 |
+
return AuthResult(
|
| 88 |
+
authorized=True,
|
| 89 |
+
approver="auto_approval",
|
| 90 |
+
timestamp=_now(),
|
| 91 |
+
reason=f"under auto-approve limit ${AUTO_APPROVE_LIMIT_USD:.0f}",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if _is_demo_mode():
|
| 95 |
+
await asyncio.sleep(DEMO_APPROVAL_DELAY_S)
|
| 96 |
+
approver = AUTHORIZED_APPROVERS[0] if AUTHORIZED_APPROVERS else "demo_approver"
|
| 97 |
+
log.info(
|
| 98 |
+
"auth_demo_approved",
|
| 99 |
+
component="auth.proxlock",
|
| 100 |
+
sku=part_sku,
|
| 101 |
+
amount_usd=amount_usd,
|
| 102 |
+
approver=approver,
|
| 103 |
+
)
|
| 104 |
+
return AuthResult(
|
| 105 |
+
authorized=True,
|
| 106 |
+
approver=approver,
|
| 107 |
+
timestamp=_now(),
|
| 108 |
+
reason="demo mode mock approval",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
return await _live_request(part_sku, amount_usd, requester)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def _live_request(part_sku: str, amount_usd: float, requester: str) -> AuthResult:
|
| 115 |
+
api_key = os.environ["PROXLOCK_API_KEY"]
|
| 116 |
+
device_id = os.environ["PROXLOCK_DEVICE_ID"]
|
| 117 |
+
body = {
|
| 118 |
+
"device_id": device_id,
|
| 119 |
+
"requester": requester,
|
| 120 |
+
"budget_action": {
|
| 121 |
+
"sku": part_sku,
|
| 122 |
+
"amount_usd": amount_usd,
|
| 123 |
+
"currency": "USD",
|
| 124 |
+
},
|
| 125 |
+
}
|
| 126 |
+
try:
|
| 127 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 128 |
+
resp = await client.post(
|
| 129 |
+
f"{PROXLOCK_BASE_URL}/authorize",
|
| 130 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 131 |
+
json=body,
|
| 132 |
+
)
|
| 133 |
+
resp.raise_for_status()
|
| 134 |
+
data = resp.json()
|
| 135 |
+
except httpx.HTTPError as exc:
|
| 136 |
+
log.error(
|
| 137 |
+
"auth_live_failed",
|
| 138 |
+
component="auth.proxlock",
|
| 139 |
+
sku=part_sku,
|
| 140 |
+
error=str(exc),
|
| 141 |
+
)
|
| 142 |
+
return AuthResult(False, approver="", timestamp=_now(), reason=f"proxlock error: {exc}")
|
| 143 |
+
|
| 144 |
+
return AuthResult(
|
| 145 |
+
authorized=bool(data.get("authorized", False)),
|
| 146 |
+
approver=str(data.get("approver", "")),
|
| 147 |
+
timestamp=str(data.get("timestamp", _now())),
|
| 148 |
+
reason=str(data.get("reason", "")),
|
| 149 |
+
)
|
src/demo/__init__.py
ADDED
|
File without changes
|
src/demo/app.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FactoryFlow Gradio demo — single-page judge-facing UI.
|
| 2 |
+
|
| 3 |
+
Run: PYTHONPATH=. python -m src.demo.app
|
| 4 |
+
Then open http://localhost:7860
|
| 5 |
+
|
| 6 |
+
Layout (4 panels):
|
| 7 |
+
1. Sensor feed — live anomaly score line chart, polled every 2s
|
| 8 |
+
2. Inference panel — score gauge + RUL countdown
|
| 9 |
+
3. Agent activity log — scrolling text of CrewAI agent steps + auth/pay
|
| 10 |
+
4. Procurement result — supplier card with auth + payment status
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import json
|
| 16 |
+
from typing import Any, Generator
|
| 17 |
+
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import numpy as np
|
| 20 |
+
import structlog
|
| 21 |
+
from dotenv import load_dotenv
|
| 22 |
+
|
| 23 |
+
from src.agents.orchestrator import run_cycle
|
| 24 |
+
from src.agents.tools import sensor_tool
|
| 25 |
+
from src.auth.proxlock import request_authorization
|
| 26 |
+
from src.demo.components import DemoState, format_gauge, format_supplier_card
|
| 27 |
+
from src.inference.anomaly_detector import detect
|
| 28 |
+
from src.payments.x402_client import execute_purchase
|
| 29 |
+
from src.sensor.simulator import BearingFaultSimulator
|
| 30 |
+
|
| 31 |
+
load_dotenv()
|
| 32 |
+
log = structlog.get_logger()
|
| 33 |
+
|
| 34 |
+
POLL_INTERVAL_S = 2.0
|
| 35 |
+
|
| 36 |
+
_demo_simulator = BearingFaultSimulator() # used by the auto-poller only
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _poll_sensor(state: DemoState) -> DemoState:
|
| 40 |
+
window = _demo_simulator.generate_window()
|
| 41 |
+
fft = np.array(window.fft_window, dtype=np.float32)
|
| 42 |
+
result = detect(fft)
|
| 43 |
+
state.last_state_label = _demo_simulator.state
|
| 44 |
+
state.last_dominant_hz = window.dominant_freq_hz
|
| 45 |
+
state.append_score(result.score, result.rul_hours)
|
| 46 |
+
return state
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _set_state(label: str, state: DemoState) -> DemoState:
|
| 50 |
+
_demo_simulator.set_state(label)
|
| 51 |
+
sensor_tool.force_state(label) # keep agent's in-process simulator in sync
|
| 52 |
+
state.log(f"sensor state forced → {label}")
|
| 53 |
+
return state
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def on_poll(state: DemoState):
|
| 57 |
+
state = _poll_sensor(state)
|
| 58 |
+
df = state.score_dataframe()
|
| 59 |
+
last_score = state.score_points[-1][1] if state.score_points else 0.0
|
| 60 |
+
last_rul = state.score_points[-1][2] if state.score_points else 0.0
|
| 61 |
+
gauge = format_gauge(last_score, last_rul, state.last_state_label, state.last_dominant_hz)
|
| 62 |
+
return state, df, gauge
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def on_force_state(label: str, state: DemoState):
|
| 66 |
+
state = _set_state(label, state)
|
| 67 |
+
return state, state.log_text()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def on_run_cycle(state: DemoState) -> Generator[tuple[Any, ...], None, None]:
|
| 71 |
+
"""Generator: streams updates to log + supplier card as the pipeline runs."""
|
| 72 |
+
state.log("▶ kicking off CrewAI cycle (Engineer → Procurement)")
|
| 73 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
crew_out = run_cycle(force_state=None)
|
| 77 |
+
except Exception as exc:
|
| 78 |
+
state.log(f"✗ Crew failed: {exc}")
|
| 79 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
eng = crew_out.get("engineer", {})
|
| 83 |
+
proc = crew_out.get("procurement", {})
|
| 84 |
+
state.log(f"engineer → SKU={eng.get('part_sku')} urgency={eng.get('urgency')}")
|
| 85 |
+
state.log(f"procurement → {proc.get('selected_supplier')} @ ${proc.get('unit_price_usd')}")
|
| 86 |
+
state.procurement = proc
|
| 87 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 88 |
+
|
| 89 |
+
if not proc.get("selected_supplier"):
|
| 90 |
+
state.log("· no procurement action required, stopping")
|
| 91 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
sku = proc.get("part_sku") or eng.get("part_sku") or ""
|
| 95 |
+
amount = float(proc.get("unit_price_usd") or 0.0)
|
| 96 |
+
supplier = proc.get("selected_supplier") or ""
|
| 97 |
+
purchase_url = proc.get("purchase_url") or ""
|
| 98 |
+
|
| 99 |
+
state.log(f"requesting Proxlock authorization for ${amount:.2f}…")
|
| 100 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 101 |
+
auth = asyncio.run(request_authorization(sku, amount))
|
| 102 |
+
state.auth = auth.as_dict()
|
| 103 |
+
state.log(f"auth → authorized={auth.authorized} approver={auth.approver}")
|
| 104 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 105 |
+
|
| 106 |
+
if not auth.authorized:
|
| 107 |
+
state.log("✗ authorization denied — payment skipped")
|
| 108 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
state.log("executing X402 payment…")
|
| 112 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 113 |
+
pay = asyncio.run(execute_purchase(sku, amount, supplier, purchase_url, auth))
|
| 114 |
+
state.payment = pay.as_dict()
|
| 115 |
+
state.log(f"payment → status={pay.status} txn={pay.transaction_id}")
|
| 116 |
+
yield state, state.log_text(), format_supplier_card(state)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def build_app() -> gr.Blocks:
|
| 120 |
+
with gr.Blocks(title="FactoryFlow — Autonomous Predictive Maintenance") as app:
|
| 121 |
+
gr.Markdown(
|
| 122 |
+
"# FactoryFlow\n"
|
| 123 |
+
"Sensor → MOMENT anomaly detection (AMD GPU) → CrewAI agents → "
|
| 124 |
+
"Proxlock auth → X402 payment. End-to-end autonomous procurement."
|
| 125 |
+
)
|
| 126 |
+
state = gr.State(DemoState())
|
| 127 |
+
|
| 128 |
+
with gr.Row():
|
| 129 |
+
with gr.Column(scale=2):
|
| 130 |
+
gr.Markdown("### Vibration sensor — anomaly score (live)")
|
| 131 |
+
chart = gr.LinePlot(
|
| 132 |
+
x="tick",
|
| 133 |
+
y="anomaly_score",
|
| 134 |
+
x_title="window #",
|
| 135 |
+
y_title="anomaly score",
|
| 136 |
+
y_lim=[0.0, 1.0],
|
| 137 |
+
height=260,
|
| 138 |
+
show_label=False,
|
| 139 |
+
)
|
| 140 |
+
with gr.Column(scale=1):
|
| 141 |
+
gauge = gr.Markdown(format_gauge(0.0, 48.0, "normal", 0.0))
|
| 142 |
+
with gr.Row():
|
| 143 |
+
btn_normal = gr.Button("Normal", size="sm")
|
| 144 |
+
btn_degrade = gr.Button("Degrading", size="sm")
|
| 145 |
+
btn_fail = gr.Button("Imminent failure", size="sm", variant="stop")
|
| 146 |
+
|
| 147 |
+
with gr.Row():
|
| 148 |
+
with gr.Column():
|
| 149 |
+
gr.Markdown("### Agent activity log")
|
| 150 |
+
log_box = gr.Textbox(
|
| 151 |
+
value="",
|
| 152 |
+
lines=14,
|
| 153 |
+
max_lines=14,
|
| 154 |
+
interactive=False,
|
| 155 |
+
show_label=False,
|
| 156 |
+
)
|
| 157 |
+
run_btn = gr.Button("▶ Run agent cycle", variant="primary")
|
| 158 |
+
with gr.Column():
|
| 159 |
+
gr.Markdown("### Procurement")
|
| 160 |
+
card = gr.Markdown(format_supplier_card(DemoState()))
|
| 161 |
+
|
| 162 |
+
timer = gr.Timer(POLL_INTERVAL_S)
|
| 163 |
+
timer.tick(on_poll, inputs=[state], outputs=[state, chart, gauge])
|
| 164 |
+
|
| 165 |
+
btn_normal.click(on_force_state, inputs=[gr.State("normal"), state],
|
| 166 |
+
outputs=[state, log_box])
|
| 167 |
+
btn_degrade.click(on_force_state, inputs=[gr.State("degrading"), state],
|
| 168 |
+
outputs=[state, log_box])
|
| 169 |
+
btn_fail.click(on_force_state, inputs=[gr.State("imminent_failure"), state],
|
| 170 |
+
outputs=[state, log_box])
|
| 171 |
+
|
| 172 |
+
run_btn.click(on_run_cycle, inputs=[state], outputs=[state, log_box, card])
|
| 173 |
+
|
| 174 |
+
return app
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def main() -> None:
|
| 178 |
+
app = build_app()
|
| 179 |
+
app.queue().launch(server_name="0.0.0.0", server_port=7860, show_error=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
main()
|
src/demo/components.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reusable Gradio components and small formatting helpers for the demo UI."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from collections import deque
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
HISTORY_WINDOWS = 60 # last N polled points shown in the chart
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class DemoState:
|
| 16 |
+
score_points: deque[tuple[int, float, float]] = field(
|
| 17 |
+
default_factory=lambda: deque(maxlen=HISTORY_WINDOWS)
|
| 18 |
+
) # (tick, score, rul_hours)
|
| 19 |
+
tick: int = 0
|
| 20 |
+
agent_log: list[str] = field(default_factory=list)
|
| 21 |
+
last_state_label: str = "normal"
|
| 22 |
+
last_dominant_hz: float = 0.0
|
| 23 |
+
procurement: dict[str, Any] | None = None
|
| 24 |
+
auth: dict[str, Any] | None = None
|
| 25 |
+
payment: dict[str, Any] | None = None
|
| 26 |
+
|
| 27 |
+
def append_score(self, score: float, rul_hours: float) -> None:
|
| 28 |
+
self.tick += 1
|
| 29 |
+
self.score_points.append((self.tick, score, rul_hours))
|
| 30 |
+
|
| 31 |
+
def score_dataframe(self) -> pd.DataFrame:
|
| 32 |
+
if not self.score_points:
|
| 33 |
+
return pd.DataFrame({"tick": [], "anomaly_score": []})
|
| 34 |
+
ticks, scores, _ = zip(*self.score_points)
|
| 35 |
+
return pd.DataFrame({"tick": list(ticks), "anomaly_score": list(scores)})
|
| 36 |
+
|
| 37 |
+
def log(self, msg: str) -> None:
|
| 38 |
+
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
| 39 |
+
self.agent_log.append(f"[{ts}] {msg}")
|
| 40 |
+
if len(self.agent_log) > 200:
|
| 41 |
+
del self.agent_log[: len(self.agent_log) - 200]
|
| 42 |
+
|
| 43 |
+
def log_text(self) -> str:
|
| 44 |
+
return "\n".join(self.agent_log[-40:])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def format_gauge(score: float, rul_hours: float, state_label: str, dominant_hz: float) -> str:
|
| 48 |
+
bar_len = 24
|
| 49 |
+
filled = int(round(score * bar_len))
|
| 50 |
+
bar = "█" * filled + "░" * (bar_len - filled)
|
| 51 |
+
severity = "NORMAL"
|
| 52 |
+
if score >= 0.85:
|
| 53 |
+
severity = "IMMINENT FAILURE"
|
| 54 |
+
elif score >= 0.75:
|
| 55 |
+
severity = "ACTION REQUIRED"
|
| 56 |
+
elif score >= 0.5:
|
| 57 |
+
severity = "DEGRADING"
|
| 58 |
+
return (
|
| 59 |
+
f"### Live Inference\n"
|
| 60 |
+
f"```\n"
|
| 61 |
+
f"score {score:0.3f} [{bar}]\n"
|
| 62 |
+
f"rul {rul_hours:0.1f} h\n"
|
| 63 |
+
f"state {state_label}\n"
|
| 64 |
+
f"dom_hz {dominant_hz:0.1f}\n"
|
| 65 |
+
f"status {severity}\n"
|
| 66 |
+
f"```"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def format_supplier_card(state: DemoState) -> str:
|
| 71 |
+
proc = state.procurement
|
| 72 |
+
auth = state.auth
|
| 73 |
+
pay = state.payment
|
| 74 |
+
if proc is None:
|
| 75 |
+
return "_No procurement cycle has been run yet._"
|
| 76 |
+
|
| 77 |
+
if not proc.get("selected_supplier"):
|
| 78 |
+
return f"**No procurement action.** Reason: `{proc.get('reason', 'not specified')}`"
|
| 79 |
+
|
| 80 |
+
lines = [
|
| 81 |
+
"### Procurement",
|
| 82 |
+
f"- **Supplier:** {proc.get('selected_supplier')}",
|
| 83 |
+
f"- **SKU:** `{proc.get('part_sku')}`",
|
| 84 |
+
f"- **Price:** ${float(proc.get('unit_price_usd', 0.0)):.2f}",
|
| 85 |
+
f"- **Delivery:** {proc.get('delivery_days')} days",
|
| 86 |
+
f"- **Stock:** {proc.get('stock_status')}",
|
| 87 |
+
f"- **URL:** {proc.get('purchase_url')}",
|
| 88 |
+
]
|
| 89 |
+
if auth:
|
| 90 |
+
lines.append("")
|
| 91 |
+
lines.append("### Authorization (Proxlock)")
|
| 92 |
+
lines.append(f"- **Authorized:** {auth.get('authorized')}")
|
| 93 |
+
lines.append(f"- **Approver:** `{auth.get('approver') or '—'}`")
|
| 94 |
+
lines.append(f"- **Reason:** {auth.get('reason')}")
|
| 95 |
+
if pay:
|
| 96 |
+
lines.append("")
|
| 97 |
+
lines.append("### Payment (X402)")
|
| 98 |
+
lines.append(f"- **Status:** {pay.get('status')}")
|
| 99 |
+
lines.append(f"- **Transaction:** `{pay.get('transaction_id')}`")
|
| 100 |
+
lines.append(f"- **Amount:** ${float(pay.get('amount_usd', 0.0)):.2f}")
|
| 101 |
+
if pay.get("receipt_url"):
|
| 102 |
+
lines.append(f"- **Receipt:** {pay.get('receipt_url')}")
|
| 103 |
+
return "\n".join(lines)
|
src/demo/demo_script.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FactoryFlow — 3-Minute Judge Demo
|
| 2 |
+
|
| 3 |
+
Open the app at `http://localhost:7860` (or the HF Space URL). The sensor feed
|
| 4 |
+
and live inference panel start polling automatically.
|
| 5 |
+
|
| 6 |
+
## 0:00 — Hook (15s)
|
| 7 |
+
> "Unplanned downtime costs manufacturers $50k per hour. FactoryFlow eliminates
|
| 8 |
+
> it by connecting a vibration sensor directly to autonomous procurement —
|
| 9 |
+
> no humans in the loop except the final budget approval."
|
| 10 |
+
|
| 11 |
+
## 0:15 — Show the sensor (30s)
|
| 12 |
+
Point at the **anomaly score** chart and the **Live Inference** panel:
|
| 13 |
+
> "This is a bearing on a CNC spindle. MOMENT — a time-series foundation
|
| 14 |
+
> model — is running on AMD MI300X right now, scoring every FFT window in
|
| 15 |
+
> under 50ms. Right now we're in the `normal` band; score around 0.3."
|
| 16 |
+
|
| 17 |
+
(Optional flex) Pull up the terminal where `python -m src.inference.rocm_check`
|
| 18 |
+
showed the AMD device name and VRAM — that's the proof MOMENT is GPU-served.
|
| 19 |
+
|
| 20 |
+
## 0:45 — Trigger failure (30s)
|
| 21 |
+
Click **Imminent failure**. Watch the score climb past 0.85 within a few polls:
|
| 22 |
+
> "The model picks up the bearing's characteristic 85 Hz BPFO fault frequency.
|
| 23 |
+
> Score 0.92, RUL down to roughly 6 hours."
|
| 24 |
+
|
| 25 |
+
## 1:15 — Run the agent cycle (45s)
|
| 26 |
+
Click **▶ Run agent cycle**. The log streams in real time:
|
| 27 |
+
> "The Engineer Agent reads the latest window, identifies it as a bearing
|
| 28 |
+
> fault, and looks up SKU-BRG-6205. The Procurement Agent — Qwen3-8B on
|
| 29 |
+
> AMD — queries three suppliers via Apify and picks the best price-vs-RUL
|
| 30 |
+
> trade-off: BearingPoint at $47, two-day delivery."
|
| 31 |
+
|
| 32 |
+
## 2:00 — Auth + payment (30s)
|
| 33 |
+
The same cycle continues into Proxlock and X402 in the same log:
|
| 34 |
+
> "Proxlock authorizes — only the factory lead's identity unlocks the budget.
|
| 35 |
+
> Approved. X402 fires the programmable payment. Transaction ID `sim_…`,
|
| 36 |
+
> simulated for the demo but the call shape is identical to live."
|
| 37 |
+
|
| 38 |
+
## 2:30 — Close (30s)
|
| 39 |
+
> "Sensor spike to confirmed PO: under a minute. Every component runs on
|
| 40 |
+
> AMD: MOMENT for inference, Qwen3-8B for agent reasoning. The MCP-connected
|
| 41 |
+
> factory floor isn't a roadmap item — it's running on screen."
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Recovery cues if something goes wrong
|
| 46 |
+
|
| 47 |
+
- **Chart isn't updating.** Click **Normal** then **Imminent failure** to force
|
| 48 |
+
a state change; that re-arms the simulator and the next poll will land.
|
| 49 |
+
- **Run agent cycle hangs.** The OpenAI/vLLM call is slow. Talk to the auth +
|
| 50 |
+
payment slide while you wait — it's the same scripted beat.
|
| 51 |
+
- **Procurement returns no action.** The Engineer flagged the score as
|
| 52 |
+
routine. Click **Imminent failure** again and re-run.
|
src/inference/__init__.py
ADDED
|
File without changes
|
src/inference/anomaly_detector.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score a 512-point FFT window with MOMENT and derive an anomaly score + RUL.
|
| 2 |
+
|
| 3 |
+
MOMENT operates on patches of 8 timesteps. A 512-point window therefore yields
|
| 4 |
+
64 patches, exactly matching the architecture's expected sequence length.
|
| 5 |
+
|
| 6 |
+
Anomaly scoring strategy:
|
| 7 |
+
score = normalized reconstruction MSE between the model's output and input.
|
| 8 |
+
A module-level calibration max is updated online so that early-demo windows
|
| 9 |
+
don't all score 1.0 — the score is bounded to [0, 1].
|
| 10 |
+
|
| 11 |
+
RUL estimate:
|
| 12 |
+
Heuristic mapping from anomaly score to remaining useful life in hours,
|
| 13 |
+
anchored on RUL_ALERT_HOURS from .env. Above the alert threshold the RUL
|
| 14 |
+
decays linearly toward 0; below it, RUL stays at the alert value.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import structlog
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
from src.inference.model_loader import get_model
|
| 27 |
+
|
| 28 |
+
log = structlog.get_logger()
|
| 29 |
+
|
| 30 |
+
WINDOW_SIZE = 512
|
| 31 |
+
PATCH_SIZE = 8
|
| 32 |
+
ANOMALY_THRESHOLD = float(os.getenv("ANOMALY_THRESHOLD", "0.75"))
|
| 33 |
+
RUL_ALERT_HOURS = float(os.getenv("RUL_ALERT_HOURS", "48"))
|
| 34 |
+
|
| 35 |
+
_calibration_max: float = 1e-6 # running max of raw MSE seen so far
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class AnomalyResult:
|
| 40 |
+
score: float # in [0, 1]; >ANOMALY_THRESHOLD = action required
|
| 41 |
+
rul_hours: float # estimated remaining useful life
|
| 42 |
+
confidence: float # in [0, 1]; rises as calibration matures
|
| 43 |
+
raw_mse: float
|
| 44 |
+
latency_ms: float
|
| 45 |
+
|
| 46 |
+
def as_dict(self) -> dict[str, float]:
|
| 47 |
+
return {
|
| 48 |
+
"score": round(self.score, 4),
|
| 49 |
+
"rul_hours": round(self.rul_hours, 2),
|
| 50 |
+
"confidence": round(self.confidence, 3),
|
| 51 |
+
"raw_mse": round(self.raw_mse, 6),
|
| 52 |
+
"latency_ms": round(self.latency_ms, 2),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _estimate_rul(score: float) -> float:
|
| 57 |
+
if score <= ANOMALY_THRESHOLD:
|
| 58 |
+
return RUL_ALERT_HOURS
|
| 59 |
+
# Linear decay from alert threshold (full RUL) to score=1.0 (zero RUL).
|
| 60 |
+
span = max(1e-6, 1.0 - ANOMALY_THRESHOLD)
|
| 61 |
+
fraction_remaining = max(0.0, (1.0 - score) / span)
|
| 62 |
+
return round(RUL_ALERT_HOURS * fraction_remaining, 2)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _to_tensor(window: np.ndarray, bundle) -> torch.Tensor:
|
| 66 |
+
if window.shape[-1] != WINDOW_SIZE:
|
| 67 |
+
raise ValueError(
|
| 68 |
+
f"anomaly_detector expects {WINDOW_SIZE}-point window, got {window.shape}"
|
| 69 |
+
)
|
| 70 |
+
arr = window.astype(np.float32, copy=False)
|
| 71 |
+
# MOMENT expects shape (batch, n_channels, seq_len).
|
| 72 |
+
tensor = torch.from_numpy(arr).reshape(1, 1, WINDOW_SIZE)
|
| 73 |
+
return tensor.to(bundle.device.torch_device).to(bundle.dtype)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _calibration_update(raw_mse: float) -> tuple[float, float]:
|
| 77 |
+
global _calibration_max
|
| 78 |
+
_calibration_max = max(_calibration_max, raw_mse)
|
| 79 |
+
score = float(np.clip(raw_mse / _calibration_max, 0.0, 1.0))
|
| 80 |
+
# Confidence proxy: how saturated calibration is. Low when _calibration_max
|
| 81 |
+
# is still tiny (early demo windows); high once we've seen real spikes.
|
| 82 |
+
confidence = float(np.clip(_calibration_max / 1.0, 0.05, 1.0))
|
| 83 |
+
return score, confidence
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def detect(window: np.ndarray) -> AnomalyResult:
|
| 87 |
+
bundle = get_model()
|
| 88 |
+
started = time.perf_counter()
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
x = _to_tensor(window, bundle)
|
| 92 |
+
with torch.no_grad():
|
| 93 |
+
output = bundle.model(x_enc=x)
|
| 94 |
+
reconstruction = getattr(output, "reconstruction", None)
|
| 95 |
+
if reconstruction is None:
|
| 96 |
+
# MOMENTPipeline returns an object with .reconstruction; fall back to indexing.
|
| 97 |
+
reconstruction = output[0] if hasattr(output, "__getitem__") else output
|
| 98 |
+
diff = (reconstruction.float() - x.float()).pow(2).mean()
|
| 99 |
+
raw_mse = float(diff.item())
|
| 100 |
+
except Exception as exc:
|
| 101 |
+
log.error(
|
| 102 |
+
"inference_failed",
|
| 103 |
+
component="inference.anomaly_detector",
|
| 104 |
+
error=str(exc),
|
| 105 |
+
)
|
| 106 |
+
raise
|
| 107 |
+
|
| 108 |
+
score, confidence = _calibration_update(raw_mse)
|
| 109 |
+
rul = _estimate_rul(score)
|
| 110 |
+
latency_ms = (time.perf_counter() - started) * 1000.0
|
| 111 |
+
|
| 112 |
+
log.info(
|
| 113 |
+
"inference_complete",
|
| 114 |
+
component="inference.anomaly_detector",
|
| 115 |
+
score=round(score, 4),
|
| 116 |
+
rul_hours=rul,
|
| 117 |
+
raw_mse=round(raw_mse, 6),
|
| 118 |
+
latency_ms=round(latency_ms, 2),
|
| 119 |
+
device=bundle.device.torch_device,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return AnomalyResult(
|
| 123 |
+
score=score,
|
| 124 |
+
rul_hours=rul,
|
| 125 |
+
confidence=confidence,
|
| 126 |
+
raw_mse=raw_mse,
|
| 127 |
+
latency_ms=latency_ms,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def reset_calibration() -> None:
|
| 132 |
+
global _calibration_max
|
| 133 |
+
_calibration_max = 1e-6
|
src/inference/model_loader.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load MOMENT-1-large once and cache as a module-level singleton.
|
| 2 |
+
|
| 3 |
+
The momentfm wrapper hard-pins old transformers/numpy in its package metadata,
|
| 4 |
+
but the actual code works on modern stacks — install with ``--no-deps``.
|
| 5 |
+
|
| 6 |
+
Public API:
|
| 7 |
+
get_model() -> MomentBundle
|
| 8 |
+
Returns the cached (model, device_info) bundle, loading on first call.
|
| 9 |
+
reset_model()
|
| 10 |
+
Drops the cached model — useful in tests.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import structlog
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
from src.inference.rocm_check import DeviceInfo, detect_device
|
| 23 |
+
|
| 24 |
+
log = structlog.get_logger()
|
| 25 |
+
|
| 26 |
+
MODEL_NAME = os.getenv("MOMENT_MODEL", "AutonLab/MOMENT-1-large")
|
| 27 |
+
TASK_NAME = "reconstruction" # MOMENT anomaly detection uses reconstruction error
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class MomentBundle:
|
| 32 |
+
model: Any
|
| 33 |
+
device: DeviceInfo
|
| 34 |
+
dtype: torch.dtype
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_bundle: MomentBundle | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _select_dtype(device: DeviceInfo) -> torch.dtype:
|
| 41 |
+
# fp16 only pays off on real GPUs; CPU/MPS prefer fp32 for stability.
|
| 42 |
+
if device.backend in ("rocm", "cuda"):
|
| 43 |
+
return torch.float16
|
| 44 |
+
return torch.float32
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _load() -> MomentBundle:
|
| 48 |
+
from momentfm import MOMENTPipeline # imported lazily; heavy dep
|
| 49 |
+
|
| 50 |
+
device = detect_device()
|
| 51 |
+
dtype = _select_dtype(device)
|
| 52 |
+
|
| 53 |
+
log.info(
|
| 54 |
+
"model_load_start",
|
| 55 |
+
component="inference.model_loader",
|
| 56 |
+
model=MODEL_NAME,
|
| 57 |
+
device=device.torch_device,
|
| 58 |
+
dtype=str(dtype),
|
| 59 |
+
)
|
| 60 |
+
started = time.perf_counter()
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
model = MOMENTPipeline.from_pretrained(
|
| 64 |
+
MODEL_NAME,
|
| 65 |
+
model_kwargs={"task_name": TASK_NAME},
|
| 66 |
+
)
|
| 67 |
+
model.init()
|
| 68 |
+
model.to(device.torch_device).to(dtype)
|
| 69 |
+
model.eval()
|
| 70 |
+
except Exception as exc:
|
| 71 |
+
log.error(
|
| 72 |
+
"model_load_failed",
|
| 73 |
+
component="inference.model_loader",
|
| 74 |
+
model=MODEL_NAME,
|
| 75 |
+
error=str(exc),
|
| 76 |
+
)
|
| 77 |
+
raise
|
| 78 |
+
|
| 79 |
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
| 80 |
+
log.info(
|
| 81 |
+
"model_load_complete",
|
| 82 |
+
component="inference.model_loader",
|
| 83 |
+
model=MODEL_NAME,
|
| 84 |
+
device=device.torch_device,
|
| 85 |
+
load_ms=round(elapsed_ms, 1),
|
| 86 |
+
)
|
| 87 |
+
return MomentBundle(model=model, device=device, dtype=dtype)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def get_model() -> MomentBundle:
|
| 91 |
+
global _bundle
|
| 92 |
+
if _bundle is None:
|
| 93 |
+
_bundle = _load()
|
| 94 |
+
return _bundle
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def reset_model() -> None:
|
| 98 |
+
global _bundle
|
| 99 |
+
_bundle = None
|
src/inference/rocm_check.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify the inference device available to FactoryFlow.
|
| 2 |
+
|
| 3 |
+
Run as a module: ``python -m src.inference.rocm_check``
|
| 4 |
+
|
| 5 |
+
Selection order (override with ``AMD_DEVICE`` env var):
|
| 6 |
+
1. AMD ROCm GPU (torch built with ROCm exposes ``torch.version.hip``)
|
| 7 |
+
2. NVIDIA CUDA GPU (useful for local dev on non-AMD boxes)
|
| 8 |
+
3. Apple MPS (MacBook fallback)
|
| 9 |
+
4. CPU
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import platform
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
import structlog
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
log = structlog.get_logger()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class DeviceInfo:
|
| 25 |
+
backend: str # "rocm" | "cuda" | "mps" | "cpu"
|
| 26 |
+
torch_device: str # value to pass to ``.to(...)``
|
| 27 |
+
name: str
|
| 28 |
+
vram_gb: float | None
|
| 29 |
+
runtime_version: str | None
|
| 30 |
+
|
| 31 |
+
def as_dict(self) -> dict[str, object]:
|
| 32 |
+
return {
|
| 33 |
+
"backend": self.backend,
|
| 34 |
+
"torch_device": self.torch_device,
|
| 35 |
+
"name": self.name,
|
| 36 |
+
"vram_gb": self.vram_gb,
|
| 37 |
+
"runtime_version": self.runtime_version,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _detect_rocm() -> DeviceInfo | None:
|
| 42 |
+
if not torch.cuda.is_available():
|
| 43 |
+
return None
|
| 44 |
+
hip_version = getattr(torch.version, "hip", None)
|
| 45 |
+
if not hip_version:
|
| 46 |
+
return None
|
| 47 |
+
props = torch.cuda.get_device_properties(0)
|
| 48 |
+
return DeviceInfo(
|
| 49 |
+
backend="rocm",
|
| 50 |
+
torch_device="cuda", # ROCm exposes the CUDA-compatible API
|
| 51 |
+
name=props.name,
|
| 52 |
+
vram_gb=round(props.total_memory / 1024**3, 1),
|
| 53 |
+
runtime_version=hip_version,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _detect_cuda() -> DeviceInfo | None:
|
| 58 |
+
if not torch.cuda.is_available():
|
| 59 |
+
return None
|
| 60 |
+
if getattr(torch.version, "hip", None):
|
| 61 |
+
return None # already handled by ROCm path
|
| 62 |
+
props = torch.cuda.get_device_properties(0)
|
| 63 |
+
return DeviceInfo(
|
| 64 |
+
backend="cuda",
|
| 65 |
+
torch_device="cuda",
|
| 66 |
+
name=props.name,
|
| 67 |
+
vram_gb=round(props.total_memory / 1024**3, 1),
|
| 68 |
+
runtime_version=torch.version.cuda,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _detect_mps() -> DeviceInfo | None:
|
| 73 |
+
mps = getattr(torch.backends, "mps", None)
|
| 74 |
+
if mps is None or not mps.is_available():
|
| 75 |
+
return None
|
| 76 |
+
return DeviceInfo(
|
| 77 |
+
backend="mps",
|
| 78 |
+
torch_device="mps",
|
| 79 |
+
name=f"Apple MPS ({platform.processor() or platform.machine()})",
|
| 80 |
+
vram_gb=None,
|
| 81 |
+
runtime_version=None,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _detect_cpu() -> DeviceInfo:
|
| 86 |
+
return DeviceInfo(
|
| 87 |
+
backend="cpu",
|
| 88 |
+
torch_device="cpu",
|
| 89 |
+
name=platform.processor() or platform.machine() or "CPU",
|
| 90 |
+
vram_gb=None,
|
| 91 |
+
runtime_version=None,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def detect_device() -> DeviceInfo:
|
| 96 |
+
"""Return the best available device, honoring ``AMD_DEVICE`` override."""
|
| 97 |
+
override = os.getenv("AMD_DEVICE", "").strip().lower()
|
| 98 |
+
if override == "cpu":
|
| 99 |
+
return _detect_cpu()
|
| 100 |
+
|
| 101 |
+
info = _detect_rocm() or _detect_cuda() or _detect_mps() or _detect_cpu()
|
| 102 |
+
log.info(
|
| 103 |
+
"device_detected",
|
| 104 |
+
component="inference.rocm_check",
|
| 105 |
+
backend=info.backend,
|
| 106 |
+
torch_device=info.torch_device,
|
| 107 |
+
name=info.name,
|
| 108 |
+
vram_gb=info.vram_gb,
|
| 109 |
+
runtime_version=info.runtime_version,
|
| 110 |
+
torch_version=torch.__version__,
|
| 111 |
+
)
|
| 112 |
+
return info
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def main() -> None:
|
| 116 |
+
info = detect_device()
|
| 117 |
+
print(f"torch: {torch.__version__}")
|
| 118 |
+
print(f"backend: {info.backend}")
|
| 119 |
+
print(f"torch_device: {info.torch_device}")
|
| 120 |
+
print(f"name: {info.name}")
|
| 121 |
+
print(f"vram_gb: {info.vram_gb}")
|
| 122 |
+
print(f"runtime_version: {info.runtime_version}")
|
| 123 |
+
if info.backend == "rocm":
|
| 124 |
+
print("✓ AMD ROCm GPU detected — ready for MI300X demo run.")
|
| 125 |
+
elif info.backend in ("cuda", "mps"):
|
| 126 |
+
print(f"⚠ Running on {info.backend} — fine for local dev, swap to ROCm for the demo.")
|
| 127 |
+
else:
|
| 128 |
+
print("⚠ CPU only — inference will be slow; use for unit tests only.")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
main()
|
src/payments/__init__.py
ADDED
|
File without changes
|
src/payments/x402_client.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""X402 programmable-payment client for autonomous purchase execution.
|
| 2 |
+
|
| 3 |
+
In ``DEMO_MODE=true`` the call logs the payload and returns a mock transaction
|
| 4 |
+
that is visually identical to a real one in the UI. In live mode it POSTs to
|
| 5 |
+
the X402 payment endpoint with the merchant credentials from .env.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import uuid
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
import structlog
|
| 16 |
+
|
| 17 |
+
from src.auth.proxlock import AuthResult
|
| 18 |
+
|
| 19 |
+
log = structlog.get_logger()
|
| 20 |
+
|
| 21 |
+
X402_BASE_URL = os.getenv("X402_BASE_URL", "https://api.x402.xyz/v1")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class PaymentResult:
|
| 26 |
+
transaction_id: str
|
| 27 |
+
status: str # "confirmed" | "simulated" | "failed"
|
| 28 |
+
amount_usd: float
|
| 29 |
+
timestamp: str
|
| 30 |
+
receipt_url: str
|
| 31 |
+
error: str | None = None
|
| 32 |
+
|
| 33 |
+
def as_dict(self) -> dict[str, object]:
|
| 34 |
+
return {
|
| 35 |
+
"transaction_id": self.transaction_id,
|
| 36 |
+
"status": self.status,
|
| 37 |
+
"amount_usd": self.amount_usd,
|
| 38 |
+
"timestamp": self.timestamp,
|
| 39 |
+
"receipt_url": self.receipt_url,
|
| 40 |
+
"error": self.error,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _is_demo_mode() -> bool:
|
| 45 |
+
return os.getenv("DEMO_MODE", "true").lower() in ("1", "true", "yes")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _now() -> str:
|
| 49 |
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def execute_purchase(
|
| 53 |
+
part_sku: str,
|
| 54 |
+
amount_usd: float,
|
| 55 |
+
supplier: str,
|
| 56 |
+
purchase_url: str,
|
| 57 |
+
auth: AuthResult,
|
| 58 |
+
) -> PaymentResult:
|
| 59 |
+
"""Execute the payment after Proxlock has authorized it."""
|
| 60 |
+
if not auth.authorized:
|
| 61 |
+
log.warning(
|
| 62 |
+
"payment_blocked",
|
| 63 |
+
component="payments.x402_client",
|
| 64 |
+
sku=part_sku,
|
| 65 |
+
reason="auth not granted",
|
| 66 |
+
)
|
| 67 |
+
return PaymentResult(
|
| 68 |
+
transaction_id="",
|
| 69 |
+
status="failed",
|
| 70 |
+
amount_usd=amount_usd,
|
| 71 |
+
timestamp=_now(),
|
| 72 |
+
receipt_url="",
|
| 73 |
+
error="authorization not granted",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
payload = {
|
| 77 |
+
"merchant_id": os.getenv("X402_MERCHANT_ID", "demo_merchant"),
|
| 78 |
+
"amount_usd": amount_usd,
|
| 79 |
+
"currency": "USD",
|
| 80 |
+
"metadata": {
|
| 81 |
+
"part_sku": part_sku,
|
| 82 |
+
"supplier": supplier,
|
| 83 |
+
"purchase_url": purchase_url,
|
| 84 |
+
"approver": auth.approver,
|
| 85 |
+
"approved_at": auth.timestamp,
|
| 86 |
+
},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if _is_demo_mode():
|
| 90 |
+
txn_id = f"sim_{uuid.uuid4().hex[:12]}"
|
| 91 |
+
log.info(
|
| 92 |
+
"payment_simulated",
|
| 93 |
+
component="payments.x402_client",
|
| 94 |
+
transaction_id=txn_id,
|
| 95 |
+
sku=part_sku,
|
| 96 |
+
amount_usd=amount_usd,
|
| 97 |
+
supplier=supplier,
|
| 98 |
+
)
|
| 99 |
+
return PaymentResult(
|
| 100 |
+
transaction_id=txn_id,
|
| 101 |
+
status="simulated",
|
| 102 |
+
amount_usd=amount_usd,
|
| 103 |
+
timestamp=_now(),
|
| 104 |
+
receipt_url=f"https://demo.factoryflow.local/receipts/{txn_id}",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return await _live_charge(payload, amount_usd)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
async def _live_charge(payload: dict, amount_usd: float) -> PaymentResult:
|
| 111 |
+
api_key = os.environ["X402_API_KEY"]
|
| 112 |
+
try:
|
| 113 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 114 |
+
resp = await client.post(
|
| 115 |
+
f"{X402_BASE_URL}/charges",
|
| 116 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 117 |
+
json=payload,
|
| 118 |
+
)
|
| 119 |
+
resp.raise_for_status()
|
| 120 |
+
data = resp.json()
|
| 121 |
+
except httpx.HTTPError as exc:
|
| 122 |
+
log.error(
|
| 123 |
+
"payment_live_failed",
|
| 124 |
+
component="payments.x402_client",
|
| 125 |
+
error=str(exc),
|
| 126 |
+
)
|
| 127 |
+
return PaymentResult(
|
| 128 |
+
transaction_id="",
|
| 129 |
+
status="failed",
|
| 130 |
+
amount_usd=amount_usd,
|
| 131 |
+
timestamp=_now(),
|
| 132 |
+
receipt_url="",
|
| 133 |
+
error=str(exc),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return PaymentResult(
|
| 137 |
+
transaction_id=str(data.get("transaction_id", "")),
|
| 138 |
+
status=str(data.get("status", "confirmed")),
|
| 139 |
+
amount_usd=float(data.get("amount_usd", amount_usd)),
|
| 140 |
+
timestamp=str(data.get("timestamp", _now())),
|
| 141 |
+
receipt_url=str(data.get("receipt_url", "")),
|
| 142 |
+
)
|
src/sensor/__init__.py
ADDED
|
File without changes
|
src/sensor/mcp_server.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP server exposing the bearing-fault simulator over SSE on port 8765.
|
| 2 |
+
|
| 3 |
+
Resources
|
| 4 |
+
- ``sensor://vibration/latest`` — most recent window as JSON
|
| 5 |
+
- ``sensor://vibration/stream`` — alias for ``latest`` (single-shot read; clients poll)
|
| 6 |
+
- ``sensor://vibration/history`` — last 60 windows as a JSON array
|
| 7 |
+
|
| 8 |
+
Tools
|
| 9 |
+
- ``set_state(state)`` — force the simulator into a named state
|
| 10 |
+
- ``get_stats()`` — current RMS, dominant frequency, sample count
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import json
|
| 17 |
+
from collections import deque
|
| 18 |
+
from typing import Deque
|
| 19 |
+
|
| 20 |
+
import structlog
|
| 21 |
+
from mcp.server.fastmcp import FastMCP
|
| 22 |
+
|
| 23 |
+
from src.sensor.simulator import BearingFaultSimulator, SensorWindow
|
| 24 |
+
|
| 25 |
+
log = structlog.get_logger()
|
| 26 |
+
|
| 27 |
+
EMIT_INTERVAL_SECONDS: float = 5.0
|
| 28 |
+
HISTORY_SIZE: int = 60
|
| 29 |
+
SERVER_PORT: int = 8765
|
| 30 |
+
|
| 31 |
+
_simulator: BearingFaultSimulator = BearingFaultSimulator()
|
| 32 |
+
_history: Deque[SensorWindow] = deque(maxlen=HISTORY_SIZE)
|
| 33 |
+
_emit_task: asyncio.Task | None = None
|
| 34 |
+
|
| 35 |
+
mcp: FastMCP = FastMCP("factoryflow-sensor")
|
| 36 |
+
mcp.settings.host = "0.0.0.0"
|
| 37 |
+
mcp.settings.port = SERVER_PORT
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _emit_once() -> SensorWindow:
|
| 41 |
+
window = _simulator.generate_window()
|
| 42 |
+
_history.append(window)
|
| 43 |
+
return window
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def _emit_loop() -> None:
|
| 47 |
+
log.info(
|
| 48 |
+
"emit_loop_start",
|
| 49 |
+
component="sensor.mcp_server",
|
| 50 |
+
interval_s=EMIT_INTERVAL_SECONDS,
|
| 51 |
+
)
|
| 52 |
+
# Seed immediately so the first read after startup is non-empty.
|
| 53 |
+
_emit_once()
|
| 54 |
+
while True:
|
| 55 |
+
try:
|
| 56 |
+
await asyncio.sleep(EMIT_INTERVAL_SECONDS)
|
| 57 |
+
_emit_once()
|
| 58 |
+
except asyncio.CancelledError:
|
| 59 |
+
log.info(
|
| 60 |
+
"emit_loop_cancelled",
|
| 61 |
+
component="sensor.mcp_server",
|
| 62 |
+
)
|
| 63 |
+
raise
|
| 64 |
+
except Exception as exc: # pragma: no cover — keep loop alive
|
| 65 |
+
log.error(
|
| 66 |
+
"emit_loop_error",
|
| 67 |
+
component="sensor.mcp_server",
|
| 68 |
+
error=str(exc),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def _ensure_emit_loop() -> None:
|
| 73 |
+
global _emit_task
|
| 74 |
+
if _emit_task is None or _emit_task.done():
|
| 75 |
+
_emit_task = asyncio.create_task(_emit_loop())
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@mcp.resource("sensor://vibration/latest")
|
| 79 |
+
async def latest_window() -> str:
|
| 80 |
+
"""Return the most recent sensor window as JSON."""
|
| 81 |
+
await _ensure_emit_loop()
|
| 82 |
+
if not _history:
|
| 83 |
+
_emit_once()
|
| 84 |
+
return json.dumps(_history[-1].to_dict())
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@mcp.resource("sensor://vibration/stream")
|
| 88 |
+
async def stream_window() -> str:
|
| 89 |
+
"""Single-shot read of the latest window (clients poll for streaming)."""
|
| 90 |
+
return await latest_window()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@mcp.resource("sensor://vibration/history")
|
| 94 |
+
async def history_windows() -> str:
|
| 95 |
+
"""Return the last ``HISTORY_SIZE`` windows as a JSON array."""
|
| 96 |
+
await _ensure_emit_loop()
|
| 97 |
+
return json.dumps([w.to_dict() for w in _history])
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@mcp.tool()
|
| 101 |
+
async def set_state(state: str) -> str:
|
| 102 |
+
"""Force the simulator into ``normal``, ``degrading``, or ``imminent_failure``."""
|
| 103 |
+
try:
|
| 104 |
+
_simulator.set_state(state)
|
| 105 |
+
except ValueError as exc:
|
| 106 |
+
log.warning(
|
| 107 |
+
"set_state_invalid",
|
| 108 |
+
component="sensor.mcp_server",
|
| 109 |
+
requested=state,
|
| 110 |
+
error=str(exc),
|
| 111 |
+
)
|
| 112 |
+
return json.dumps({"ok": False, "error": str(exc)})
|
| 113 |
+
return json.dumps(
|
| 114 |
+
{
|
| 115 |
+
"ok": True,
|
| 116 |
+
"state": _simulator.state,
|
| 117 |
+
"degradation_level": round(_simulator.degradation_level, 3),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@mcp.tool()
|
| 123 |
+
async def get_stats() -> str:
|
| 124 |
+
"""Return current RMS, dominant frequency, and total samples emitted."""
|
| 125 |
+
await _ensure_emit_loop()
|
| 126 |
+
if not _history:
|
| 127 |
+
_emit_once()
|
| 128 |
+
latest = _history[-1]
|
| 129 |
+
return json.dumps(
|
| 130 |
+
{
|
| 131 |
+
"state": _simulator.state,
|
| 132 |
+
"degradation_level": round(_simulator.degradation_level, 3),
|
| 133 |
+
"dominant_freq_hz": latest.dominant_freq_hz,
|
| 134 |
+
"rms_velocity": latest.rms_velocity,
|
| 135 |
+
"sample_count": len(_history),
|
| 136 |
+
"last_timestamp": latest.timestamp,
|
| 137 |
+
}
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def main() -> None:
|
| 142 |
+
log.info(
|
| 143 |
+
"server_start",
|
| 144 |
+
component="sensor.mcp_server",
|
| 145 |
+
port=SERVER_PORT,
|
| 146 |
+
transport="sse",
|
| 147 |
+
)
|
| 148 |
+
mcp.run(transport="sse")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
main()
|
src/sensor/simulator.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic bearing-fault vibration simulator for FactoryFlow.
|
| 2 |
+
|
| 3 |
+
Generates 512-sample time-domain windows at 10 kHz that mimic the vibration
|
| 4 |
+
signature of a 6205 ball bearing. State transitions between healthy and
|
| 5 |
+
imminent_failure inject a growing sinusoid at the BPFO frequency (~85 Hz at
|
| 6 |
+
1800 RPM), which MOMENT later flags as a reconstruction anomaly.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from typing import Literal
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import structlog
|
| 18 |
+
|
| 19 |
+
log = structlog.get_logger()
|
| 20 |
+
|
| 21 |
+
WINDOW_SIZE: int = 512
|
| 22 |
+
SAMPLE_RATE_HZ: float = 10_000.0
|
| 23 |
+
BEARING_FAULT_FREQ_HZ: float = 85.0
|
| 24 |
+
DEGRADATION_RAMP_PER_TICK: float = 0.01
|
| 25 |
+
|
| 26 |
+
State = Literal["normal", "degrading", "imminent_failure"]
|
| 27 |
+
|
| 28 |
+
STATE_PROFILES: dict[str, dict[str, float]] = {
|
| 29 |
+
"normal": {"degradation_floor": 0.05, "noise_scale": 1.0},
|
| 30 |
+
"degrading": {"degradation_floor": 0.0, "noise_scale": 1.2},
|
| 31 |
+
"imminent_failure": {"degradation_floor": 0.92, "noise_scale": 1.5},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class SensorWindow:
|
| 37 |
+
timestamp: str
|
| 38 |
+
state_label: str
|
| 39 |
+
fft_window: list[float]
|
| 40 |
+
dominant_freq_hz: float
|
| 41 |
+
rms_velocity: float
|
| 42 |
+
|
| 43 |
+
def to_dict(self) -> dict:
|
| 44 |
+
return {
|
| 45 |
+
"timestamp": self.timestamp,
|
| 46 |
+
"state_label": self.state_label,
|
| 47 |
+
"fft_window": self.fft_window,
|
| 48 |
+
"dominant_freq_hz": self.dominant_freq_hz,
|
| 49 |
+
"rms_velocity": self.rms_velocity,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class BearingFaultSimulator:
|
| 55 |
+
state: State = "normal"
|
| 56 |
+
degradation_level: float = 0.05
|
| 57 |
+
sample_rate_hz: float = SAMPLE_RATE_HZ
|
| 58 |
+
window_size: int = WINDOW_SIZE
|
| 59 |
+
fault_freq_hz: float = BEARING_FAULT_FREQ_HZ
|
| 60 |
+
_rng: np.random.Generator = field(
|
| 61 |
+
default_factory=lambda: np.random.default_rng(seed=42)
|
| 62 |
+
)
|
| 63 |
+
_tick: int = 0
|
| 64 |
+
|
| 65 |
+
def set_state(self, state: str) -> None:
|
| 66 |
+
if state not in STATE_PROFILES:
|
| 67 |
+
raise ValueError(
|
| 68 |
+
f"unknown state '{state}'; must be one of {list(STATE_PROFILES)}"
|
| 69 |
+
)
|
| 70 |
+
previous = self.state
|
| 71 |
+
self.state = state # type: ignore[assignment]
|
| 72 |
+
floor = STATE_PROFILES[state]["degradation_floor"]
|
| 73 |
+
self.degradation_level = max(self.degradation_level, floor)
|
| 74 |
+
if state == "normal":
|
| 75 |
+
self.degradation_level = floor
|
| 76 |
+
log.info(
|
| 77 |
+
"state_change",
|
| 78 |
+
component="sensor.simulator",
|
| 79 |
+
previous=previous,
|
| 80 |
+
new=state,
|
| 81 |
+
degradation_level=round(self.degradation_level, 3),
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def _advance_degradation(self) -> None:
|
| 85 |
+
if self.state == "degrading":
|
| 86 |
+
self.degradation_level = min(
|
| 87 |
+
0.85, self.degradation_level + DEGRADATION_RAMP_PER_TICK
|
| 88 |
+
)
|
| 89 |
+
elif self.state == "imminent_failure":
|
| 90 |
+
self.degradation_level = min(0.98, self.degradation_level + 0.005)
|
| 91 |
+
# normal: leave at floor
|
| 92 |
+
|
| 93 |
+
def inject_fault_peak(
|
| 94 |
+
self, signal: np.ndarray, freq_hz: float, amplitude: float
|
| 95 |
+
) -> np.ndarray:
|
| 96 |
+
t = np.arange(signal.size, dtype=np.float64) / self.sample_rate_hz
|
| 97 |
+
phase = self._rng.uniform(0.0, 2 * math.pi)
|
| 98 |
+
# Add fundamental + 2x harmonic — bearing faults excite harmonics too.
|
| 99 |
+
peak = amplitude * np.sin(2 * math.pi * freq_hz * t + phase)
|
| 100 |
+
peak += 0.4 * amplitude * np.sin(2 * math.pi * 2 * freq_hz * t + phase)
|
| 101 |
+
return signal + peak
|
| 102 |
+
|
| 103 |
+
def generate_window(self) -> SensorWindow:
|
| 104 |
+
self._advance_degradation()
|
| 105 |
+
self._tick += 1
|
| 106 |
+
|
| 107 |
+
profile = STATE_PROFILES[self.state]
|
| 108 |
+
noise_scale = profile["noise_scale"]
|
| 109 |
+
|
| 110 |
+
# Broadband mechanical noise (healthy bearing baseline).
|
| 111 |
+
signal = self._rng.normal(0.0, 0.05 * noise_scale, size=self.window_size)
|
| 112 |
+
|
| 113 |
+
# Always include a small running-machine 30 Hz shaft component.
|
| 114 |
+
t = np.arange(self.window_size, dtype=np.float64) / self.sample_rate_hz
|
| 115 |
+
signal += 0.08 * np.sin(2 * math.pi * 30.0 * t)
|
| 116 |
+
|
| 117 |
+
# Inject the fault peak scaled by current degradation.
|
| 118 |
+
fault_amplitude = 0.6 * self.degradation_level
|
| 119 |
+
if fault_amplitude > 0.01:
|
| 120 |
+
signal = self.inject_fault_peak(
|
| 121 |
+
signal, self.fault_freq_hz, fault_amplitude
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Impulsive transients spike during imminent failure.
|
| 125 |
+
if self.state == "imminent_failure" and self._rng.random() < 0.5:
|
| 126 |
+
idx = int(self._rng.integers(0, self.window_size))
|
| 127 |
+
signal[idx] += self._rng.choice([-1.0, 1.0]) * 0.7
|
| 128 |
+
|
| 129 |
+
dominant_hz, rms = _spectral_stats(signal, self.sample_rate_hz)
|
| 130 |
+
|
| 131 |
+
window = SensorWindow(
|
| 132 |
+
timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
| 133 |
+
state_label=self.state,
|
| 134 |
+
fft_window=[float(x) for x in signal],
|
| 135 |
+
dominant_freq_hz=float(dominant_hz),
|
| 136 |
+
rms_velocity=float(rms),
|
| 137 |
+
)
|
| 138 |
+
log.debug(
|
| 139 |
+
"window_emitted",
|
| 140 |
+
component="sensor.simulator",
|
| 141 |
+
tick=self._tick,
|
| 142 |
+
state=self.state,
|
| 143 |
+
degradation=round(self.degradation_level, 3),
|
| 144 |
+
dominant_hz=round(dominant_hz, 1),
|
| 145 |
+
rms=round(rms, 3),
|
| 146 |
+
)
|
| 147 |
+
return window
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _spectral_stats(signal: np.ndarray, sample_rate_hz: float) -> tuple[float, float]:
|
| 151 |
+
spectrum = np.abs(np.fft.rfft(signal))
|
| 152 |
+
freqs = np.fft.rfftfreq(signal.size, d=1.0 / sample_rate_hz)
|
| 153 |
+
# Ignore DC component when picking dominant frequency.
|
| 154 |
+
if spectrum.size > 1:
|
| 155 |
+
dominant_hz = float(freqs[1 + int(np.argmax(spectrum[1:]))])
|
| 156 |
+
else:
|
| 157 |
+
dominant_hz = 0.0
|
| 158 |
+
rms = float(np.sqrt(np.mean(signal**2)))
|
| 159 |
+
return dominant_hz, rms
|