NeonClary Cursor commited on
Commit
808ab9a
·
1 Parent(s): 51239c8

Add Decidron network simulator

Browse files

Backend: deterministic decidron engine (models, network, engine, stats,
diagrams) under backend/app/decidron with a REST router at
/api/decidron; networkx dependency and patent-shaped seed network +
sample commands.

Frontend: DecidronSimulator view (vis-network diagram, sensor input,
command builder, results, stats, history) wired into App.js; api.js
rewritten for the Decidron endpoints; vis-network/vis-data added.

Docs/config: full README (patent context, start, UI workflow, command
schema, API curl reference, troubleshooting); trimmed .env.example and
docker-compose to the deterministic simulator.

Co-authored-by: Cursor <cursoragent@cursor.com>

.env.example CHANGED
@@ -1,25 +1,11 @@
1
- # HANA API (Neon BrainForge)
2
- HANA_BASE_URL=https://hana.neonaialpha.com
3
- HANA_USERNAME=guest
4
- HANA_PASSWORD=password
5
- # BrainForge/Security (4090-x1-3): OpenAI-compatible API key for direct vLLM (same as HF Space API_KEY in brainforge-webapp).
6
- # NOT used for HANA /auth/login — see NeonGeckoCom/brainforge-webapp docker_overlay/config/config.yaml
7
- HANA_KLATCHAT_PASSWORD=sk_klatchat2024
8
- # Direct vLLM host (default matches demo 4090-x1-3)
9
- # NEON_SECURITY_VLLM_BASE_URL=https://4090-x1-3.neonaiservices2.com/vllm0
10
 
11
- # Neon vLLM direct access
12
- VLLM_API_KEY=sk_n30nn30n
13
 
14
- # Comparison providers
15
- FIREWORKS_API_KEY=your-fireworks-api-key-here
16
- TOGETHER_API_KEY=your-together-api-key-here
17
- OPENAI_API_KEY=your-openai-api-key-here
18
- GEMINI_API_KEY=your-gemini-api-key-here
19
-
20
- # Orchestrator LLM (used to monitor conversation endings)
21
- # Defaults to gpt-4o-mini via OpenAI if OPENAI_API_KEY is set
22
- ORCHESTRATOR_MODEL=gpt-4o-mini
23
-
24
- # App
25
- CORS_ORIGINS=http://localhost:3000
 
1
+ # Decidron Network Simulator config.
2
+ #
3
+ # The deterministic simulator requires no API keys. The only settings
4
+ # are CORS (for CRA dev on :3000 talking to the backend on :7860) and an
5
+ # optional host port override for docker compose.
 
 
 
 
6
 
7
+ # Comma-separated allowed CORS origins.
8
+ CORS_ORIGINS=http://localhost:3000,http://localhost:7860
9
 
10
+ # Host port to publish the container on (container always listens on 7860).
11
+ # CCAI_HOST_PORT=7860
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -3,6 +3,7 @@ __pycache__/
3
  .env
4
  node_modules/
5
  frontend/build/
 
6
  .DS_Store
7
  *.egg-info/
8
  dist/
 
3
  .env
4
  node_modules/
5
  frontend/build/
6
+ backend/static/
7
  .DS_Store
8
  *.egg-info/
9
  dist/
README.md CHANGED
@@ -1,128 +1,229 @@
1
- ---
2
- title: Collaborative Conversational AI (CCAI) Demo
3
- emoji: 🤝
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
- app_port: 7860
8
- hf_oauth: true
9
- hf_oauth_scopes:
10
- - read-repos
11
- pinned: false
12
- ---
13
-
14
- # Collaborative Conversational AI (CCAI) Demo
15
-
16
- A demo of **Collaborative Conversational AI (CCAI)** - Neon.ai's patented
17
- group-discussion technology. Up to 9 participants (any mix of AI personas,
18
- human-defined "expert" personas, or - in the future - real humans, agents,
19
- tools, or sensors) hold a structured group conversation facilitated by a
20
- neutral **Orchestrator**, with the goal of reaching a real group decision
21
- the way a thoughtful human meeting would.
22
-
23
- This repo descends from
24
- [NeonClary/LLMChats3](https://github.com/NeonClary/LLMChats3) and reuses
25
- its color scheme, branding, settings menu structure, and chat formatting
26
- verbatim. The CCAI multi-participant orchestration, expert-persona modal,
27
- participant sidebar, and table view are layered on top.
28
-
29
- ## Architecture (one-pager)
30
-
31
- - **Frontend** (React 19, react-markdown, lucide-react) - lives in
32
- `frontend/`. Talks SSE to the backend.
33
- - **Backend** (FastAPI, httpx) - lives in `backend/`. Routes:
34
- - `GET /api/personas` — Neon HANA personas (vanilla/RAG filtered),
35
- bundled extra personas, and (echoed) expert personas.
36
- - `GET /api/demo-questions` — the bank of 10 long-context demo prompts.
37
- - `POST /api/chat/start` — kicks off a CCAI session and returns SSE.
38
- - `POST /api/chat/{id}/continue?reason=…` — resumes a paused session.
39
- - `GET /api/chat/{id}/export?fmt=txt|md|csv-table` — exports.
40
- - `GET /api/chat/{id}/table` — JSON for the table view.
41
- - **Orchestrator state machine** — six phases (Initial Opinions,
42
- Critique x2, Status Assessment, Finalization, Consensus, Closure)
43
- with two failsafes (60+20 messages, 100+50 orchestrator calls) and
44
- per-participant on-demand context summarization.
45
-
46
- ## CCAI Phase Overview
47
-
48
- 1. **Initial Opinions.** Each participant offers an independent first
49
- opinion. The orchestrator builds a per-participant **Credential
50
- Summary** in the background.
51
- 2. **Critique x 2.** Each participant gets two turns to critique
52
- others, ask follow-ups, and revise. After Phase 2 the Credential
53
- Summary is refreshed.
54
- 3. **Status Assessment.** The orchestrator either proceeds or runs
55
- targeted follow-ups (max 3 iterations).
56
- 4. **Finalization.** Each participant either revises their own opinion
57
- or endorses another's.
58
- 5. **Consensus Gathering.** Allied participants advocate, solo
59
- participants seek allies / switch / propose compromises. The
60
- orchestrator routes addressed-to messages.
61
- 6. **Closure.** Majority-report (with weighted dissent), or
62
- unaddressed-factor probe + retry, or no-consensus report.
63
-
64
- Thinking traces (`<think>`, `<reasoning>`, etc.) are stripped from every
65
- LLM response in `backend/app/utils/sanitize.py` before being stored,
66
- displayed, or fed back to the orchestrator/summarizer/Credential builder.
67
-
68
- ## Quick Start (local development)
69
 
70
  ```bash
71
- cp .env.example .env
72
- # Edit .env with your API keys
 
 
 
 
 
 
 
 
73
 
74
- # Backend
 
 
75
  cd backend
 
 
76
  pip install -r requirements.txt
77
- uvicorn app.main:app --reload --port 8000
 
 
 
78
 
79
- # Frontend (separate terminal)
80
  cd frontend
81
  npm install
 
 
82
  npm start
83
  ```
84
 
85
- ## Docker
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  ```bash
88
- cp .env.example .env
89
- # Edit .env with your API keys
90
- docker compose up --build
91
- # Open http://localhost:7860
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  ```
93
 
94
- ## HuggingFace Spaces Deployment
95
-
96
- Deployed as a Docker Space (`app_port: 7860`).
97
-
98
- Required Space Secrets:
99
-
100
- - `HANA_USERNAME`, `HANA_PASSWORD` — Neon HANA credentials.
101
- - `HANA_KLATCHAT_PASSWORD` (optional) — BrainForge/Security vLLM access.
102
- - Provider keys: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `FIREWORKS_API_KEY`,
103
- `TOGETHER_API_KEY`, `MISTRAL_API_KEY`.
104
- - `HF_RATE_LIMIT_DAILY=30` — daily per-IP cap (defaults to 30; org
105
- members are unlimited).
106
- - `HF_RATE_LIMIT_ORG=neongeckocom` — bypass org name.
107
- - `SESSION_SECRET` — cookie session secret for OAuth.
108
-
109
- ## Features
110
-
111
- - **Participant dropdown** in the header with three sections (Neon /
112
- Extra / Expert) and a `Create Expert Persona...` shortcut.
113
- - **Participant sidebar** with on/off slider per participant; flipping
114
- off does not deselect, and a `Remove` button appears for actually
115
- dropping someone from the conversation.
116
- - **Settings menu** with searchable orchestrator-model and summarizer-
117
- model pickers (summarizer defaults to "Same as Orchestrator"),
118
- a 3-9 max-participants stepper, per-participant model overrides, and
119
- the same display-options + downloads structure as LLMChats3.
120
- - **Two failsafes** with explicit Continue buttons (60+20 messages,
121
- 100+50 orchestrator calls).
122
- - **Exports**: `.txt`, `.md`, RFC-4180 `.csv` table, JSON API log.
123
- - **Table view** of the whole conversation with per-participant first /
124
- contribution / revised / final columns.
125
- - **localStorage persistence** for expert personas, participant
126
- selection, on/off state, model assignments, orchestrator/summarizer
127
- picks, and max-participants.
128
- - **HuggingFace OAuth** with `neongeckocom` org bypass.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Decidron Network Simulator
2
+
3
+ A deterministic, browser-based simulator for **Decidron networks** — the
4
+ coupled machine-learning decision units described in US Patent
5
+ [US11989636B1](https://patents.google.com/patent/US11989636B1/en)
6
+ ("System and method for persuadable collaborative conversational AI",
7
+ Gasper & Leeds).
8
+
9
+ You define simulated **sensor inputs** and **processing commands**
10
+ (IF a sensor matches THEN emit an output / drive an actuator / modify the
11
+ network), run the simulation, and watch the **network diagram**,
12
+ **performance statistics**, and **topology-change history** update live.
13
+
14
+ > This project started from Neon.ai's private `CCAI-Vibe-Demo` app and
15
+ > reuses its FastAPI + React scaffold, Docker setup, and visual branding.
16
+ > The CCAI multi-LLM orchestration was intentionally removed — v1 of the
17
+ > simulator is purely rule-based and needs no API keys or LLM. See
18
+ > [Relationship to CCAI](#relationship-to-ccai-and-the-patent).
19
+
20
+ ## Patent context (brief)
21
+
22
+ A **Decidron** is a machine-learning unit (MLU) with collaborative
23
+ protocols that can be **coupled/networked** with other Decidrons. The
24
+ patent highlights that "a MLU network coupled with real-time sensors and
25
+ actuators can operate industrial controls ... and increased reliability
26
+ through parallel pathways." A Decidron records its **experience**, fires
27
+ **actions**, scores **results**, and can **modify its own topology**.
28
+
29
+ This simulator models those concepts deterministically:
30
+
31
+ | Patent concept | Simulator |
32
+ | --- | --- |
33
+ | Coupled MLU node / coupling | `decidron` node / directed edge |
34
+ | Sensors and actuators | `sensor` / `actuator` nodes |
35
+ | Experience Chains (ECDS) | per-node experience log (sensor received → rule fired → actuator acted) |
36
+ | Select & perform action, results scored | command output (+ optional actuator) + performance stats |
37
+ | Dynamic/extensible topology | `network_ops` applied when a command fires |
38
+ | Parallel pathways / reliability | redundant couplings in the seed network |
39
+
40
+ ## Prerequisites
41
+
42
+ - **Docker** (recommended), or
43
+ - **Python 3.12+** and **Node 22+** for local dev.
44
+
45
+ ## Start the application
46
+
47
+ ### Docker (recommended)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  ```bash
50
+ cp .env.example .env # no secrets needed; defaults are fine
51
+ docker compose up --build
52
+ # open http://localhost:7860
53
+ ```
54
+
55
+ The container builds the React frontend, serves it as static files from
56
+ FastAPI, and listens on port 7860. Override the host port with
57
+ `CCAI_HOST_PORT` in `.env` if 7860 is taken.
58
+
59
+ ### Local development
60
 
61
+ Backend (FastAPI on :7860):
62
+
63
+ ```bash
64
  cd backend
65
+ python -m venv .venv
66
+ .venv/Scripts/activate # Windows; use source .venv/bin/activate on macOS/Linux
67
  pip install -r requirements.txt
68
+ uvicorn app.main:app --reload --port 7860
69
+ ```
70
+
71
+ Frontend (CRA dev server on :3000, proxying to the backend):
72
 
73
+ ```bash
74
  cd frontend
75
  npm install
76
+ # point the dev server at the backend:
77
+ echo "REACT_APP_API_URL=http://localhost:7860" > .env.development
78
  npm start
79
  ```
80
 
81
+ ## Web UI workflow
82
+
83
+ 1. **View the initial network diagram** — sensors (blue boxes) feed
84
+ coupled Decidrons (purple hexagons) that drive actuators (green
85
+ triangles).
86
+ 2. **Enter a simulated sensor input** — pick a channel (e.g.
87
+ `Temperature Sensor`), enter a value (e.g. `97`), and click **Queue**.
88
+ 3. **Create processing commands** — define IF (sensor + match) THEN
89
+ (output, optional actuator). The match is an `operator:value` rule.
90
+ 4. **Run the simulation** — click **Run Simulation**; queued inputs are
91
+ evaluated against all commands.
92
+ 5. **Read the results and statistics** — matched commands, emitted
93
+ outputs, and per-sensor/command/output metrics appear on the right.
94
+ 6. **Review the network-change diagram** — if a command applied
95
+ `network_ops`, a new version is snapshotted; select any version in the
96
+ **Network Modification History** to view that topology in the diagram.
97
+
98
+ ## Processing command format
99
+
100
+ A command is JSON:
101
+
102
+ ```json
103
+ {
104
+ "name": "HighTempAlert",
105
+ "sensor": "temperature",
106
+ "match": "gte:85",
107
+ "output": "Trigger failsafe review",
108
+ "actuator": "actuator-1",
109
+ "network_ops": [
110
+ { "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
111
+ { "op": "add_edge", "from": "temperature", "to": "D3" },
112
+ { "op": "remove_edge", "from": "D1", "to": "D2" }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ - `sensor` — the sensor channel/node id this command listens to.
118
+ - `match` — `operator:value`, where operator is one of `contains`,
119
+ `equals`, `startswith`, `endswith`, `regex`, `gt`, `gte`, `lt`, `lte`.
120
+ A bare value (no operator) is treated as `contains`.
121
+ - `output` — text emitted when the rule fires.
122
+ - `actuator` *(optional)* — id of an actuator node to drive.
123
+ - `network_ops` *(optional)* — topology changes: `add_node`, `add_edge`,
124
+ `remove_edge` (edges use `from`/`to`).
125
+
126
+ ## API reference
127
+
128
+ All endpoints are under `/api/decidron`.
129
+
130
+ | Method | Path | Purpose |
131
+ | --- | --- | --- |
132
+ | GET | `/api/decidron/network` | Current network + vis-network diagram JSON |
133
+ | POST | `/api/decidron/sensors/input` | Queue a simulated sensor input |
134
+ | GET | `/api/decidron/commands` | List processing commands |
135
+ | POST | `/api/decidron/commands` | Create a processing command |
136
+ | POST | `/api/decidron/run` | Process queued (or supplied) inputs |
137
+ | GET | `/api/decidron/stats` | Performance statistics |
138
+ | GET | `/api/decidron/history` | Topology-change snapshots |
139
+ | POST | `/api/decidron/reset` | Reset to the seed network |
140
+
141
+ ### curl examples
142
 
143
  ```bash
144
+ # View the initial network
145
+ curl http://localhost:7860/api/decidron/network
146
+
147
+ # Queue a sensor reading and run
148
+ curl -X POST http://localhost:7860/api/decidron/sensors/input \
149
+ -H 'Content-Type: application/json' \
150
+ -d '{"channel":"temperature","value":"97"}'
151
+
152
+ curl -X POST http://localhost:7860/api/decidron/run \
153
+ -H 'Content-Type: application/json' -d '{}'
154
+
155
+ # Create a command
156
+ curl -X POST http://localhost:7860/api/decidron/commands \
157
+ -H 'Content-Type: application/json' \
158
+ -d '{"name":"LowTemp","sensor":"temperature","match":"lt:0","output":"Freeze warning"}'
159
+
160
+ # Run with inline inputs (bypasses the queue)
161
+ curl -X POST http://localhost:7860/api/decidron/run \
162
+ -H 'Content-Type: application/json' \
163
+ -d '{"inputs":[{"channel":"temperature","value":"-5"}]}'
164
+
165
+ # Stats and history
166
+ curl http://localhost:7860/api/decidron/stats
167
+ curl http://localhost:7860/api/decidron/history
168
  ```
169
 
170
+ ## Config & seed files
171
+
172
+ - [`backend/app/config.py`](backend/app/config.py) — minimal settings
173
+ (`CORS_ORIGINS`).
174
+ - [`backend/app/data/decidron/default_network.json`](backend/app/data/decidron/default_network.json)
175
+ — the starter network (sensor → coupled Decidrons → actuator, with a
176
+ redundant coupling).
177
+ - [`backend/app/data/decidron/sample_commands.json`](backend/app/data/decidron/sample_commands.json)
178
+ — example commands (one drives an actuator, one reconfigures the
179
+ topology). Edit these to change the simulation's starting state.
180
+
181
+ ## Adding files in Cursor
182
+
183
+ - **Open the repo as a workspace**: File → Open Folder → `decidron-simulator`.
184
+ - **New file**: right-click a folder in the Explorer → New File.
185
+ - **Drag & drop** files from your file manager into the Explorer.
186
+ - **Ask Cursor in chat**: e.g. "Add `backend/app/decidron/alerts.py`".
187
+ - **Terminal**: `New-Item backend/app/decidron/alerts.py` (PowerShell) or
188
+ `touch backend/app/decidron/alerts.py` (bash).
189
+
190
+ ## Relationship to CCAI and the patent
191
+
192
+ v1 deliberately omits the patent's full pipeline: ECDS context/memory
193
+ augmentation, the embedded CCAI per Decidron, Theory-of-Mind,
194
+ persuadability, and action-reinforcement learning. A future version
195
+ could make Decidrons LLM-driven by porting the orchestration and LLM
196
+ clients from the `CCAI-Vibe-Demo` reference app. The simulator's logic is
197
+ isolated under `backend/app/decidron/` and
198
+ `frontend/src/components/decidron/` to keep that door open.
199
+
200
+ ## Project layout
201
+
202
+ ```
203
+ backend/
204
+ app/
205
+ main.py # FastAPI app: CORS, /api/health, SPA serving
206
+ config.py # minimal settings
207
+ api/decidron.py # REST router
208
+ decidron/ # simulation engine (models, network, engine, stats, diagrams)
209
+ data/decidron/ # seed network + sample commands
210
+ frontend/
211
+ src/
212
+ App.js # shell
213
+ components/Header.js # branded header
214
+ components/decidron/ # simulator UI (diagram, forms, tables, history)
215
+ utils/api.js # API client
216
+ styles/ # branding + simulator CSS
217
+ Dockerfile, docker-compose.yml
218
+ ```
219
+
220
+ ## Troubleshooting
221
+
222
+ - **Port 7860 in use** — set `CCAI_HOST_PORT` in `.env`, or run uvicorn
223
+ on another port.
224
+ - **Frontend can't reach the API in dev** — ensure
225
+ `REACT_APP_API_URL=http://localhost:7860` is set in
226
+ `frontend/.env.development` and the backend is running.
227
+ - **CORS errors** — add your origin to `CORS_ORIGINS` in `.env`.
228
+ - **Diagram doesn't render** — vis-network needs the bundled assets from
229
+ `npm install`; re-run it if you cloned without building.
backend/app/api/decidron.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """REST API for the Decidron network simulator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+ from pydantic import BaseModel
7
+
8
+ from app.decidron.engine import engine
9
+ from app.decidron.diagrams import to_vis_network
10
+ from app.decidron.models import ProcessingCommand, RunResult, SensorInput
11
+
12
+ router = APIRouter(prefix="/decidron", tags=["decidron"])
13
+
14
+
15
+ def _network_payload() -> dict:
16
+ nodes = engine.network.nodes_list()
17
+ return {
18
+ "version": engine.network.version,
19
+ "nodes": [n.model_dump() for n in nodes],
20
+ "edges": [e.model_dump(by_alias=True) for e in engine.network.edges],
21
+ "diagram": to_vis_network(nodes, engine.network.edges),
22
+ }
23
+
24
+
25
+ @router.get("/network")
26
+ async def get_network() -> dict:
27
+ return _network_payload()
28
+
29
+
30
+ @router.post("/sensors/input")
31
+ async def submit_sensor_input(sensor_input: SensorInput) -> dict:
32
+ engine.add_sensor_input(sensor_input)
33
+ return {
34
+ "queued": sensor_input.model_dump(),
35
+ "pending_count": len(engine.pending_inputs),
36
+ }
37
+
38
+
39
+ @router.get("/commands")
40
+ async def list_commands() -> list[ProcessingCommand]:
41
+ return engine.list_commands()
42
+
43
+
44
+ @router.post("/commands")
45
+ async def create_command(command: ProcessingCommand) -> ProcessingCommand:
46
+ return engine.add_command(command)
47
+
48
+
49
+ class RunRequest(BaseModel):
50
+ inputs: list[SensorInput] | None = None
51
+
52
+
53
+ @router.post("/run")
54
+ async def run_simulation(req: RunRequest | None = None) -> dict:
55
+ inputs = req.inputs if req else None
56
+ results: list[RunResult] = engine.run(inputs)
57
+ return {
58
+ "results": [r.model_dump() for r in results],
59
+ "matches": sum(1 for r in results if r.matched),
60
+ "network": _network_payload(),
61
+ }
62
+
63
+
64
+ @router.get("/stats")
65
+ async def get_stats() -> dict:
66
+ return engine.stats.as_dict()
67
+
68
+
69
+ @router.get("/history")
70
+ async def get_history() -> dict:
71
+ return {
72
+ "version": engine.network.version,
73
+ "snapshots": [
74
+ {
75
+ "version": s.version,
76
+ "reason": s.reason,
77
+ "timestamp": s.timestamp,
78
+ "diagram": to_vis_network(s.nodes, s.edges),
79
+ }
80
+ for s in engine.network.history
81
+ ],
82
+ }
83
+
84
+
85
+ @router.post("/reset")
86
+ async def reset_simulation() -> dict:
87
+ engine.reset()
88
+ return _network_payload()
backend/app/data/decidron/default_network.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nodes": [
3
+ { "id": "temperature", "label": "Temperature Sensor", "type": "sensor" },
4
+ { "id": "D1", "label": "Decidron D1", "type": "decidron" },
5
+ { "id": "D2", "label": "Decidron D2", "type": "decidron" },
6
+ { "id": "actuator-1", "label": "Failsafe Actuator", "type": "actuator" }
7
+ ],
8
+ "edges": [
9
+ { "from": "temperature", "to": "D1", "label": "senses" },
10
+ { "from": "temperature", "to": "D2", "label": "senses (redundant)" },
11
+ { "from": "D1", "to": "D2", "label": "coupling" },
12
+ { "from": "D2", "to": "D1", "label": "coupling" },
13
+ { "from": "D1", "to": "actuator-1", "label": "drives" },
14
+ { "from": "D2", "to": "actuator-1", "label": "drives (redundant)" }
15
+ ]
16
+ }
backend/app/data/decidron/sample_commands.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "name": "HighTempAlert",
4
+ "sensor": "temperature",
5
+ "match": "gte:85",
6
+ "output": "Trigger failsafe review",
7
+ "actuator": "actuator-1"
8
+ },
9
+ {
10
+ "name": "CriticalTempReconfigure",
11
+ "sensor": "temperature",
12
+ "match": "gte:95",
13
+ "output": "Spin up redundant Decidron D3 and couple it to the failsafe actuator",
14
+ "network_ops": [
15
+ { "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
16
+ { "op": "add_edge", "from": "temperature", "to": "D3" },
17
+ { "op": "add_edge", "from": "D3", "to": "actuator-1" }
18
+ ]
19
+ }
20
+ ]
backend/app/decidron/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Decidron network simulator package.
2
+
3
+ A deterministic, LLM-free simulation of the patent's (US11989636B1)
4
+ Decidron network: machine-learning decision units (Decidrons) coupled
5
+ with sensors and actuators, that record experience, fire rule-based
6
+ actions, and can modify their own topology.
7
+ """
backend/app/decidron/diagrams.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert Decidron network state into vis-network diagram JSON.
2
+
3
+ Uses ``networkx`` as the server-side graph representation and emits
4
+ ``{nodes, edges}`` shaped for the vis-network frontend, styled by node
5
+ type (sensor / decidron / actuator).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import networkx as nx
11
+
12
+ from .models import DecidronNode, Edge
13
+
14
+ _GROUP_STYLE = {
15
+ "sensor": {"shape": "box", "color": "#3b82f6"},
16
+ "decidron": {"shape": "hexagon", "color": "#8b5cf6"},
17
+ "actuator": {"shape": "triangle", "color": "#10b981"},
18
+ }
19
+
20
+
21
+ def build_graph(nodes: list[DecidronNode], edges: list[Edge]) -> nx.DiGraph:
22
+ g = nx.DiGraph()
23
+ for n in nodes:
24
+ g.add_node(n.id, label=n.label, type=n.type, experience=len(n.experience))
25
+ for e in edges:
26
+ g.add_edge(e.source, e.target, label=e.label or "")
27
+ return g
28
+
29
+
30
+ def to_vis_network(nodes: list[DecidronNode], edges: list[Edge]) -> dict:
31
+ """Return a vis-network ``{nodes, edges}`` payload."""
32
+ g = build_graph(nodes, edges)
33
+ vis_nodes = []
34
+ for node_id, data in g.nodes(data=True):
35
+ ntype = data.get("type", "decidron")
36
+ style = _GROUP_STYLE.get(ntype, {})
37
+ vis_nodes.append({
38
+ "id": node_id,
39
+ "label": data.get("label", node_id),
40
+ "group": ntype,
41
+ "title": f"{ntype} - {data.get('experience', 0)} experience events",
42
+ **style,
43
+ })
44
+ vis_edges = [
45
+ {"from": s, "to": t, "label": d.get("label", ""), "arrows": "to"}
46
+ for s, t, d in g.edges(data=True)
47
+ ]
48
+ return {"nodes": vis_nodes, "edges": vis_edges}
backend/app/decidron/engine.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic Decidron processing engine.
2
+
3
+ Ties the network topology, statistics, user commands, and queued sensor
4
+ inputs together. On each run it matches commands against sensor inputs
5
+ and, for every match:
6
+
7
+ 1. records ECDS-like experience events on the involved nodes
8
+ (sensor received -> decidron fired -> actuator acted),
9
+ 2. emits the command output and drives the target actuator (if any),
10
+ 3. applies topology ``network_ops`` (snapshotting history),
11
+ 4. updates performance statistics.
12
+
13
+ No LLM is involved -- v1 is purely rule-based.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from .models import (
24
+ ExperienceEvent,
25
+ ProcessingCommand,
26
+ RunResult,
27
+ SensorInput,
28
+ )
29
+ from .network import NetworkStore
30
+ from .stats import StatsCollector
31
+
32
+ _DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "decidron"
33
+ _SAMPLE_COMMANDS = _DATA_DIR / "sample_commands.json"
34
+
35
+
36
+ def _matches(rule: str, value: str) -> bool:
37
+ """Evaluate an ``operator:value`` match rule against a sensor value."""
38
+ if ":" in rule:
39
+ op, _, target = rule.partition(":")
40
+ op = op.strip().lower()
41
+ target = target.strip()
42
+ else:
43
+ op, target = "contains", rule.strip()
44
+
45
+ v = value.strip()
46
+
47
+ def _nums():
48
+ try:
49
+ return float(v), float(target)
50
+ except (TypeError, ValueError):
51
+ return None, None
52
+
53
+ if op == "contains":
54
+ return target.lower() in v.lower()
55
+ if op == "equals":
56
+ return v.lower() == target.lower()
57
+ if op == "startswith":
58
+ return v.lower().startswith(target.lower())
59
+ if op == "endswith":
60
+ return v.lower().endswith(target.lower())
61
+ if op == "regex":
62
+ try:
63
+ return re.search(target, v) is not None
64
+ except re.error:
65
+ return False
66
+ if op in {"gt", "lt", "gte", "lte"}:
67
+ a, b = _nums()
68
+ if a is None:
69
+ return False
70
+ return {
71
+ "gt": a > b,
72
+ "lt": a < b,
73
+ "gte": a >= b,
74
+ "lte": a <= b,
75
+ }[op]
76
+ # Unknown operator -> no match.
77
+ return False
78
+
79
+
80
+ class DecidronEngine:
81
+ def __init__(self) -> None:
82
+ self.network = NetworkStore()
83
+ self.stats = StatsCollector()
84
+ self.commands: dict[str, ProcessingCommand] = {}
85
+ self.pending_inputs: list[SensorInput] = []
86
+ self.input_log: list[SensorInput] = []
87
+ self.run_results: list[RunResult] = []
88
+ self._load_sample_commands()
89
+
90
+ def _load_sample_commands(self) -> None:
91
+ if _SAMPLE_COMMANDS.is_file():
92
+ for c in json.loads(_SAMPLE_COMMANDS.read_text(encoding="utf-8")):
93
+ cmd = ProcessingCommand(**c)
94
+ self.commands[cmd.name] = cmd
95
+
96
+ # -- commands --------------------------------------------------------
97
+
98
+ def list_commands(self) -> list[ProcessingCommand]:
99
+ return list(self.commands.values())
100
+
101
+ def add_command(self, command: ProcessingCommand) -> ProcessingCommand:
102
+ self.commands[command.name] = command
103
+ return command
104
+
105
+ # -- sensor inputs ---------------------------------------------------
106
+
107
+ def add_sensor_input(self, sensor_input: SensorInput) -> SensorInput:
108
+ self.pending_inputs.append(sensor_input)
109
+ return sensor_input
110
+
111
+ # -- experience (ECDS-like) -----------------------------------------
112
+
113
+ def _record(self, node_id: str, event: ExperienceEvent) -> None:
114
+ node = self.network.nodes.get(node_id)
115
+ if node is not None:
116
+ node.experience.append(event)
117
+
118
+ def _decidrons_for_sensor(self, sensor_node_id: str | None) -> list[str]:
119
+ """Decidron nodes coupled downstream of a sensor (fallback: all)."""
120
+ if sensor_node_id is not None:
121
+ coupled = [
122
+ nid for nid in self.network.neighbors_out(sensor_node_id)
123
+ if self.network.nodes.get(nid) and self.network.nodes[nid].type == "decidron"
124
+ ]
125
+ if coupled:
126
+ return coupled
127
+ return [n.id for n in self.network.nodes_list() if n.type == "decidron"]
128
+
129
+ # -- run -------------------------------------------------------------
130
+
131
+ def run(self, inputs: list[SensorInput] | None = None) -> list[RunResult]:
132
+ batch = inputs if inputs is not None else list(self.pending_inputs)
133
+ results: list[RunResult] = []
134
+
135
+ for si in batch:
136
+ sensor_node = self.network.find_sensor_node(si.channel)
137
+ if sensor_node is not None:
138
+ self._record(sensor_node.id, ExperienceEvent(
139
+ kind="sensor_received",
140
+ detail=f"received '{si.value}' on channel '{si.channel}'",
141
+ sensor=si.channel,
142
+ value=si.value,
143
+ ))
144
+
145
+ for cmd in self.commands.values():
146
+ if cmd.sensor.lower() != si.channel.lower():
147
+ continue
148
+ start = time.perf_counter()
149
+ matched = _matches(cmd.match, si.value)
150
+ ops_applied = 0
151
+ output = None
152
+ actuator = None
153
+
154
+ if matched:
155
+ output = cmd.output
156
+ for nid in self._decidrons_for_sensor(
157
+ sensor_node.id if sensor_node else None
158
+ ):
159
+ self._record(nid, ExperienceEvent(
160
+ kind="rule_fired",
161
+ detail=f"command '{cmd.name}' fired -> {cmd.output}",
162
+ command=cmd.name,
163
+ sensor=si.channel,
164
+ value=si.value,
165
+ ))
166
+ if cmd.actuator and cmd.actuator in self.network.nodes:
167
+ actuator = cmd.actuator
168
+ self._record(cmd.actuator, ExperienceEvent(
169
+ kind="actuator_acted",
170
+ detail=f"actuated by '{cmd.name}': {cmd.output}",
171
+ command=cmd.name,
172
+ ))
173
+ if cmd.network_ops:
174
+ ops_applied = self.network.apply_ops(
175
+ cmd.network_ops, reason=f"command '{cmd.name}' fired"
176
+ )
177
+
178
+ latency_ms = (time.perf_counter() - start) * 1000.0
179
+ self.stats.record(si.channel, cmd.name, output, matched, latency_ms)
180
+ result = RunResult(
181
+ command=cmd.name,
182
+ sensor=si.channel,
183
+ value=si.value,
184
+ matched=matched,
185
+ output=output,
186
+ actuator=actuator,
187
+ network_ops_applied=ops_applied,
188
+ latency_ms=round(latency_ms, 4),
189
+ )
190
+ results.append(result)
191
+
192
+ self.input_log.append(si)
193
+
194
+ if inputs is None:
195
+ self.pending_inputs.clear()
196
+ self.run_results.extend(results)
197
+ return results
198
+
199
+ def reset(self) -> None:
200
+ self.network.reset()
201
+ self.stats.reset()
202
+ self.pending_inputs.clear()
203
+ self.input_log.clear()
204
+ self.run_results.clear()
205
+
206
+
207
+ engine = DecidronEngine()
backend/app/decidron/models.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas for the Decidron simulator.
2
+
3
+ These model the patent's core objects in a deliberately simplified,
4
+ deterministic form:
5
+
6
+ - ``DecidronNode`` -> a coupled MLU node (decidron) or a sensor/actuator.
7
+ - ``Edge`` -> a coupling between nodes.
8
+ - ``ExperienceEvent`` -> a simplified ECDS entry recorded on a node.
9
+ - ``ProcessingCommand`` -> a user-defined IF (sensor matches) THEN
10
+ (emit output / drive actuator / modify network) rule.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Literal, Optional
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ NodeType = Literal["decidron", "sensor", "actuator"]
21
+ NetworkOpType = Literal["add_edge", "remove_edge", "add_node"]
22
+ ExperienceKind = Literal["sensor_received", "rule_fired", "actuator_acted"]
23
+
24
+
25
+ class ExperienceEvent(BaseModel):
26
+ """A simplified ECDS (Experience Chain Data Structure) entry.
27
+
28
+ The patent's signature behavior is that each unit records its
29
+ experience; in v1 we capture the salient facts of each run.
30
+ """
31
+
32
+ kind: ExperienceKind
33
+ detail: str
34
+ command: Optional[str] = None
35
+ sensor: Optional[str] = None
36
+ value: Optional[str] = None
37
+ timestamp: float = Field(default_factory=time.time)
38
+
39
+
40
+ class DecidronNode(BaseModel):
41
+ id: str
42
+ label: str
43
+ type: NodeType = "decidron"
44
+ experience: list[ExperienceEvent] = Field(default_factory=list)
45
+
46
+
47
+ class Edge(BaseModel):
48
+ """A directed coupling between two nodes (``from`` -> ``to``)."""
49
+
50
+ model_config = ConfigDict(populate_by_name=True)
51
+
52
+ source: str = Field(alias="from")
53
+ target: str = Field(alias="to")
54
+ label: Optional[str] = None
55
+
56
+
57
+ class NetworkOp(BaseModel):
58
+ """A topology modification applied when a command fires."""
59
+
60
+ model_config = ConfigDict(populate_by_name=True)
61
+
62
+ op: NetworkOpType
63
+ source: Optional[str] = Field(default=None, alias="from")
64
+ target: Optional[str] = Field(default=None, alias="to")
65
+ node: Optional[DecidronNode] = None
66
+
67
+
68
+ class SensorInput(BaseModel):
69
+ channel: str
70
+ value: str
71
+ timestamp: float = Field(default_factory=time.time)
72
+
73
+
74
+ class ProcessingCommand(BaseModel):
75
+ """A user-defined processing rule.
76
+
77
+ ``match`` is an ``operator:value`` string, e.g. ``contains:85``,
78
+ ``equals:on``, ``gt:80``. A bare value is treated as ``contains``.
79
+ """
80
+
81
+ name: str
82
+ sensor: str
83
+ match: str
84
+ output: str
85
+ actuator: Optional[str] = None
86
+ network_ops: list[NetworkOp] = Field(default_factory=list)
87
+
88
+
89
+ class NetworkSnapshot(BaseModel):
90
+ version: int
91
+ reason: str
92
+ nodes: list[DecidronNode]
93
+ edges: list[Edge]
94
+ timestamp: float = Field(default_factory=time.time)
95
+
96
+
97
+ class RunResult(BaseModel):
98
+ command: str
99
+ sensor: str
100
+ value: str
101
+ matched: bool
102
+ output: Optional[str] = None
103
+ actuator: Optional[str] = None
104
+ network_ops_applied: int = 0
105
+ latency_ms: float = 0.0
106
+ timestamp: float = Field(default_factory=time.time)
backend/app/decidron/network.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory Decidron network state with version history.
2
+
3
+ Holds the live topology (nodes + directed couplings), applies topology
4
+ modifications, and snapshots the network whenever it changes so the UI
5
+ can show before/after diagrams.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from .models import DecidronNode, Edge, NetworkOp, NetworkSnapshot
14
+
15
+ _DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "decidron"
16
+ _DEFAULT_NETWORK = _DATA_DIR / "default_network.json"
17
+
18
+
19
+ class NetworkStore:
20
+ def __init__(self) -> None:
21
+ self.nodes: dict[str, DecidronNode] = {}
22
+ self.edges: list[Edge] = []
23
+ self.version: int = 0
24
+ self.history: list[NetworkSnapshot] = []
25
+ self.load_default()
26
+
27
+ def load_default(self) -> None:
28
+ raw = json.loads(_DEFAULT_NETWORK.read_text(encoding="utf-8"))
29
+ self.nodes = {n["id"]: DecidronNode(**n) for n in raw.get("nodes", [])}
30
+ self.edges = [Edge(**e) for e in raw.get("edges", [])]
31
+ self.version = 0
32
+ self.history = []
33
+ self._snapshot("initial network")
34
+
35
+ def reset(self) -> None:
36
+ self.load_default()
37
+
38
+ # -- queries ---------------------------------------------------------
39
+
40
+ def neighbors_out(self, node_id: str) -> list[str]:
41
+ """Nodes reachable from ``node_id`` via an outgoing coupling."""
42
+ return [e.target for e in self.edges if e.source == node_id]
43
+
44
+ def nodes_list(self) -> list[DecidronNode]:
45
+ return list(self.nodes.values())
46
+
47
+ def find_sensor_node(self, channel: str) -> DecidronNode | None:
48
+ """Match a sensor channel to a sensor node by id or label."""
49
+ for node in self.nodes.values():
50
+ if node.type != "sensor":
51
+ continue
52
+ if node.id == channel or node.label.lower() == channel.lower():
53
+ return node
54
+ return None
55
+
56
+ # -- mutations -------------------------------------------------------
57
+
58
+ def _snapshot(self, reason: str) -> None:
59
+ self.history.append(
60
+ NetworkSnapshot(
61
+ version=self.version,
62
+ reason=reason,
63
+ nodes=[n.model_copy(deep=True) for n in self.nodes.values()],
64
+ edges=[e.model_copy(deep=True) for e in self.edges],
65
+ )
66
+ )
67
+
68
+ def _edge_exists(self, source: str, target: str) -> bool:
69
+ return any(e.source == source and e.target == target for e in self.edges)
70
+
71
+ def apply_op(self, op: NetworkOp) -> bool:
72
+ """Apply a single topology op. Returns True if the graph changed."""
73
+ if op.op == "add_node" and op.node is not None:
74
+ if op.node.id not in self.nodes:
75
+ self.nodes[op.node.id] = op.node
76
+ return True
77
+ return False
78
+ if op.op == "add_edge" and op.source and op.target:
79
+ if not self._edge_exists(op.source, op.target):
80
+ self.edges.append(Edge(source=op.source, target=op.target))
81
+ return True
82
+ return False
83
+ if op.op == "remove_edge" and op.source and op.target:
84
+ before = len(self.edges)
85
+ self.edges = [
86
+ e for e in self.edges
87
+ if not (e.source == op.source and e.target == op.target)
88
+ ]
89
+ return len(self.edges) != before
90
+ return False
91
+
92
+ def apply_ops(self, ops: list[NetworkOp], reason: str) -> int:
93
+ """Apply ops, snapshotting once if anything changed. Returns count."""
94
+ changed = 0
95
+ for op in ops:
96
+ if self.apply_op(op):
97
+ changed += 1
98
+ if changed:
99
+ self.version += 1
100
+ self._snapshot(reason)
101
+ return changed
backend/app/decidron/stats.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Performance statistics for the Decidron simulator.
2
+
3
+ Aggregates per-sensor, per-command, and per-output counts plus latency
4
+ and last-run timestamps -- a deterministic stand-in for the patent's
5
+ "results determined and scored" step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections import defaultdict
12
+
13
+
14
+ class StatsCollector:
15
+ def __init__(self) -> None:
16
+ self.sensors: dict[str, dict] = defaultdict(self._blank)
17
+ self.commands: dict[str, dict] = defaultdict(self._blank)
18
+ self.outputs: dict[str, dict] = defaultdict(self._blank)
19
+ self.total_runs: int = 0
20
+ self.total_matches: int = 0
21
+
22
+ @staticmethod
23
+ def _blank() -> dict:
24
+ return {"count": 0, "matches": 0, "last_run": None, "avg_latency_ms": 0.0}
25
+
26
+ @staticmethod
27
+ def _update(bucket: dict, matched: bool, latency_ms: float) -> None:
28
+ n = bucket["count"]
29
+ bucket["count"] = n + 1
30
+ if matched:
31
+ bucket["matches"] += 1
32
+ # incremental average latency
33
+ bucket["avg_latency_ms"] = (bucket["avg_latency_ms"] * n + latency_ms) / (n + 1)
34
+ bucket["last_run"] = time.time()
35
+
36
+ def record(
37
+ self,
38
+ sensor: str,
39
+ command: str,
40
+ output: str | None,
41
+ matched: bool,
42
+ latency_ms: float,
43
+ ) -> None:
44
+ self.total_runs += 1
45
+ if matched:
46
+ self.total_matches += 1
47
+ self._update(self.sensors[sensor], matched, latency_ms)
48
+ self._update(self.commands[command], matched, latency_ms)
49
+ if matched and output:
50
+ self._update(self.outputs[output], True, latency_ms)
51
+
52
+ def reset(self) -> None:
53
+ self.__init__()
54
+
55
+ def as_dict(self) -> dict:
56
+ return {
57
+ "total_runs": self.total_runs,
58
+ "total_matches": self.total_matches,
59
+ "per_sensor": dict(self.sensors),
60
+ "per_command": dict(self.commands),
61
+ "per_output": dict(self.outputs),
62
+ }
backend/app/main.py CHANGED
@@ -7,6 +7,7 @@ from fastapi import FastAPI, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
 
9
  from app.config import settings
 
10
 
11
  logging.basicConfig(level=logging.INFO)
12
  LOG = logging.getLogger(__name__)
@@ -26,6 +27,9 @@ app.add_middleware(
26
  allow_headers=["*"],
27
  )
28
 
 
 
 
29
  @app.get("/api/health")
30
  async def health():
31
  return {"status": "ok"}
 
7
  from fastapi.middleware.cors import CORSMiddleware
8
 
9
  from app.config import settings
10
+ from app.api import decidron
11
 
12
  logging.basicConfig(level=logging.INFO)
13
  LOG = logging.getLogger(__name__)
 
27
  allow_headers=["*"],
28
  )
29
 
30
+ app.include_router(decidron.router, prefix="/api")
31
+
32
+
33
  @app.get("/api/health")
34
  async def health():
35
  return {"status": "ok"}
backend/requirements.txt CHANGED
@@ -2,4 +2,5 @@ fastapi>=0.115.0
2
  uvicorn[standard]>=0.32.0
3
  pydantic-settings>=2.6.0
4
  python-dotenv>=1.0.0
 
5
  pytest>=8.0.0
 
2
  uvicorn[standard]>=0.32.0
3
  pydantic-settings>=2.6.0
4
  python-dotenv>=1.0.0
5
+ networkx>=3.3
6
  pytest>=8.0.0
docker-compose.yml CHANGED
@@ -10,5 +10,4 @@ services:
10
  - .env
11
  environment:
12
  CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
13
- HF_RATE_LIMIT_DAILY: "30"
14
  restart: unless-stopped
 
10
  - .env
11
  environment:
12
  CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
 
13
  restart: unless-stopped
frontend/package-lock.json CHANGED
@@ -11,7 +11,9 @@
11
  "lucide-react": "^0.577.0",
12
  "react": "^19.2.4",
13
  "react-dom": "^19.2.4",
14
- "react-scripts": "5.0.1"
 
 
15
  }
16
  },
17
  "node_modules/@alloc/quick-lru": {
@@ -2386,6 +2388,19 @@
2386
  "postcss-selector-parser": "^6.0.10"
2387
  }
2388
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2389
  "node_modules/@eslint-community/eslint-utils": {
2390
  "version": "4.9.1",
2391
  "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -3499,6 +3514,13 @@
3499
  "@types/node": "*"
3500
  }
3501
  },
 
 
 
 
 
 
 
3502
  "node_modules/@types/html-minifier-terser": {
3503
  "version": "6.1.0",
3504
  "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -5626,6 +5648,19 @@
5626
  "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
5627
  "license": "MIT"
5628
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
5629
  "node_modules/compressible": {
5630
  "version": "2.0.18",
5631
  "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -10794,6 +10829,13 @@
10794
  "node": ">=4.0"
10795
  }
10796
  },
 
 
 
 
 
 
 
10797
  "node_modules/keyv": {
10798
  "version": "4.5.4",
10799
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -16204,6 +16246,56 @@
16204
  "node": ">= 0.8"
16205
  }
16206
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16207
  "node_modules/w3c-hr-time": {
16208
  "version": "1.0.2",
16209
  "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
 
11
  "lucide-react": "^0.577.0",
12
  "react": "^19.2.4",
13
  "react-dom": "^19.2.4",
14
+ "react-scripts": "5.0.1",
15
+ "vis-data": "^7.1.9",
16
+ "vis-network": "^9.1.9"
17
  }
18
  },
19
  "node_modules/@alloc/quick-lru": {
 
2388
  "postcss-selector-parser": "^6.0.10"
2389
  }
2390
  },
2391
+ "node_modules/@egjs/hammerjs": {
2392
+ "version": "2.0.17",
2393
+ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
2394
+ "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
2395
+ "license": "MIT",
2396
+ "peer": true,
2397
+ "dependencies": {
2398
+ "@types/hammerjs": "^2.0.36"
2399
+ },
2400
+ "engines": {
2401
+ "node": ">=0.8.0"
2402
+ }
2403
+ },
2404
  "node_modules/@eslint-community/eslint-utils": {
2405
  "version": "4.9.1",
2406
  "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
 
3514
  "@types/node": "*"
3515
  }
3516
  },
3517
+ "node_modules/@types/hammerjs": {
3518
+ "version": "2.0.46",
3519
+ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
3520
+ "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
3521
+ "license": "MIT",
3522
+ "peer": true
3523
+ },
3524
  "node_modules/@types/html-minifier-terser": {
3525
  "version": "6.1.0",
3526
  "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
 
5648
  "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
5649
  "license": "MIT"
5650
  },
5651
+ "node_modules/component-emitter": {
5652
+ "version": "2.0.0",
5653
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz",
5654
+ "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==",
5655
+ "license": "MIT",
5656
+ "peer": true,
5657
+ "engines": {
5658
+ "node": ">=18"
5659
+ },
5660
+ "funding": {
5661
+ "url": "https://github.com/sponsors/sindresorhus"
5662
+ }
5663
+ },
5664
  "node_modules/compressible": {
5665
  "version": "2.0.18",
5666
  "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
 
10829
  "node": ">=4.0"
10830
  }
10831
  },
10832
+ "node_modules/keycharm": {
10833
+ "version": "0.4.0",
10834
+ "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz",
10835
+ "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==",
10836
+ "license": "(Apache-2.0 OR MIT)",
10837
+ "peer": true
10838
+ },
10839
  "node_modules/keyv": {
10840
  "version": "4.5.4",
10841
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
 
16246
  "node": ">= 0.8"
16247
  }
16248
  },
16249
+ "node_modules/vis-data": {
16250
+ "version": "7.1.10",
16251
+ "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.10.tgz",
16252
+ "integrity": "sha512-23juM9tdCaHTX5vyIQ7XBzsfZU0Hny+gSTwniLrfFcmw9DOm7pi3+h9iEBsoZMp5rX6KNqWwc1MF0fkAmWVuoQ==",
16253
+ "license": "(Apache-2.0 OR MIT)",
16254
+ "funding": {
16255
+ "type": "opencollective",
16256
+ "url": "https://opencollective.com/visjs"
16257
+ },
16258
+ "peerDependencies": {
16259
+ "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
16260
+ "vis-util": "^5.0.1"
16261
+ }
16262
+ },
16263
+ "node_modules/vis-network": {
16264
+ "version": "9.1.13",
16265
+ "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.13.tgz",
16266
+ "integrity": "sha512-HLeHd5KZS92qzO1kC59qMh1/FWAZxMUEwUWBwDMoj6RKj/Ajkrgy/heEYo0Zc8SZNQ2J+u6omvK2+a28GX1QuQ==",
16267
+ "license": "(Apache-2.0 OR MIT)",
16268
+ "funding": {
16269
+ "type": "opencollective",
16270
+ "url": "https://opencollective.com/visjs"
16271
+ },
16272
+ "peerDependencies": {
16273
+ "@egjs/hammerjs": "^2.0.0",
16274
+ "component-emitter": "^1.3.0 || ^2.0.0",
16275
+ "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0",
16276
+ "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
16277
+ "vis-data": "^6.3.0 || ^7.0.0",
16278
+ "vis-util": "^5.0.1"
16279
+ }
16280
+ },
16281
+ "node_modules/vis-util": {
16282
+ "version": "5.0.7",
16283
+ "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.7.tgz",
16284
+ "integrity": "sha512-E3L03G3+trvc/X4LXvBfih3YIHcKS2WrP0XTdZefr6W6Qi/2nNCqZfe4JFfJU6DcQLm6Gxqj2Pfl+02859oL5A==",
16285
+ "license": "(Apache-2.0 OR MIT)",
16286
+ "peer": true,
16287
+ "engines": {
16288
+ "node": ">=8"
16289
+ },
16290
+ "funding": {
16291
+ "type": "opencollective",
16292
+ "url": "https://opencollective.com/visjs"
16293
+ },
16294
+ "peerDependencies": {
16295
+ "@egjs/hammerjs": "^2.0.0",
16296
+ "component-emitter": "^1.3.0 || ^2.0.0"
16297
+ }
16298
+ },
16299
  "node_modules/w3c-hr-time": {
16300
  "version": "1.0.2",
16301
  "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
frontend/package.json CHANGED
@@ -6,7 +6,9 @@
6
  "lucide-react": "^0.577.0",
7
  "react": "^19.2.4",
8
  "react-dom": "^19.2.4",
9
- "react-scripts": "5.0.1"
 
 
10
  },
11
  "scripts": {
12
  "start": "react-scripts start",
 
6
  "lucide-react": "^0.577.0",
7
  "react": "^19.2.4",
8
  "react-dom": "^19.2.4",
9
+ "react-scripts": "5.0.1",
10
+ "vis-data": "^7.1.9",
11
+ "vis-network": "^9.1.9"
12
  },
13
  "scripts": {
14
  "start": "react-scripts start",
frontend/src/App.js CHANGED
@@ -1,5 +1,6 @@
1
  import React, { useState, useEffect } from 'react';
2
  import Header from './components/Header';
 
3
  import './styles/variables.css';
4
  import './styles/layout.css';
5
  import './styles/components.css';
@@ -24,9 +25,7 @@ export default function App() {
24
  <div className="app">
25
  <Header theme={theme} onToggleTheme={toggleTheme} />
26
  <main className="app-main">
27
- <p style={{ padding: '2rem', opacity: 0.7 }}>
28
- Decidron Network Simulator — UI loading…
29
- </p>
30
  </main>
31
  </div>
32
  );
 
1
  import React, { useState, useEffect } from 'react';
2
  import Header from './components/Header';
3
+ import DecidronSimulator from './components/decidron/DecidronSimulator';
4
  import './styles/variables.css';
5
  import './styles/layout.css';
6
  import './styles/components.css';
 
25
  <div className="app">
26
  <Header theme={theme} onToggleTheme={toggleTheme} />
27
  <main className="app-main">
28
+ <DecidronSimulator />
 
 
29
  </main>
30
  </div>
31
  );
frontend/src/components/decidron/CommandBuilder.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Plus } from 'lucide-react';
3
+
4
+ const MATCH_OPS = ['contains', 'equals', 'gt', 'gte', 'lt', 'lte', 'startswith', 'endswith', 'regex'];
5
+
6
+ export default function CommandBuilder({ commands, sensors, nodes, onCreate }) {
7
+ const [name, setName] = useState('');
8
+ const [sensor, setSensor] = useState('');
9
+ const [op, setOp] = useState('gte');
10
+ const [matchValue, setMatchValue] = useState('');
11
+ const [output, setOutput] = useState('');
12
+ const [actuator, setActuator] = useState('');
13
+ const [error, setError] = useState(null);
14
+
15
+ const actuators = nodes.filter((n) => n.type === 'actuator');
16
+
17
+ const submit = async (e) => {
18
+ e.preventDefault();
19
+ setError(null);
20
+ if (!name || !sensor || matchValue === '' || !output) {
21
+ setError('Name, sensor, match value, and output are required.');
22
+ return;
23
+ }
24
+ try {
25
+ await onCreate({
26
+ name,
27
+ sensor,
28
+ match: `${op}:${matchValue}`,
29
+ output,
30
+ actuator: actuator || null,
31
+ });
32
+ setName(''); setMatchValue(''); setOutput(''); setActuator('');
33
+ } catch (err) {
34
+ setError(err.message);
35
+ }
36
+ };
37
+
38
+ return (
39
+ <div className="decidron-panel">
40
+ <h2>Processing Commands</h2>
41
+ <p className="panel-hint">
42
+ Define IF (sensor matches) THEN (emit output, optionally drive an
43
+ actuator). Commands fire when you run the simulation.
44
+ </p>
45
+
46
+ {error && <div className="decidron-error">{error}</div>}
47
+
48
+ <form onSubmit={submit}>
49
+ <div className="decidron-row">
50
+ <div className="decidron-field" style={{ flex: 1 }}>
51
+ <label>Name</label>
52
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="HighTempAlert" />
53
+ </div>
54
+ <div className="decidron-field" style={{ flex: 1 }}>
55
+ <label>Sensor</label>
56
+ <select value={sensor} onChange={(e) => setSensor(e.target.value)}>
57
+ <option value="">Select…</option>
58
+ {sensors.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
59
+ </select>
60
+ </div>
61
+ </div>
62
+ <div className="decidron-row">
63
+ <div className="decidron-field" style={{ width: 120 }}>
64
+ <label>Match</label>
65
+ <select value={op} onChange={(e) => setOp(e.target.value)}>
66
+ {MATCH_OPS.map((o) => <option key={o} value={o}>{o}</option>)}
67
+ </select>
68
+ </div>
69
+ <div className="decidron-field" style={{ flex: 1 }}>
70
+ <label>Value</label>
71
+ <input value={matchValue} onChange={(e) => setMatchValue(e.target.value)} placeholder="85" />
72
+ </div>
73
+ <div className="decidron-field" style={{ flex: 1 }}>
74
+ <label>Actuator (optional)</label>
75
+ <select value={actuator} onChange={(e) => setActuator(e.target.value)}>
76
+ <option value="">None</option>
77
+ {actuators.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}
78
+ </select>
79
+ </div>
80
+ </div>
81
+ <div className="decidron-field">
82
+ <label>Output</label>
83
+ <input value={output} onChange={(e) => setOutput(e.target.value)} placeholder="Trigger failsafe review" />
84
+ </div>
85
+ <button type="submit" className="decidron-btn">
86
+ <Plus size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
87
+ Add Command
88
+ </button>
89
+ </form>
90
+
91
+ <div style={{ marginTop: '0.75rem' }}>
92
+ {commands.map((c) => (
93
+ <div className="decidron-cmd" key={c.name}>
94
+ <strong>{c.name}</strong>: IF <code>{c.sensor}</code> <code>{c.match}</code> THEN {c.output}
95
+ {c.actuator && <> → drives <code>{c.actuator}</code></>}
96
+ {c.network_ops && c.network_ops.length > 0 && <> <span className="decidron-chip">{c.network_ops.length} network op(s)</span></>}
97
+ </div>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
frontend/src/components/decidron/DecidronSimulator.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { RotateCcw } from 'lucide-react';
3
+ import NetworkDiagram from './NetworkDiagram';
4
+ import SensorInputForm from './SensorInputForm';
5
+ import CommandBuilder from './CommandBuilder';
6
+ import ResultsPanel from './ResultsPanel';
7
+ import StatsTable from './StatsTable';
8
+ import HistoryTimeline from './HistoryTimeline';
9
+ import {
10
+ getNetwork, getCommands, getStats, getHistory,
11
+ submitSensorInput, runSimulation, createCommand, resetSimulation,
12
+ } from '../../utils/api';
13
+ import '../../styles/decidron.css';
14
+
15
+ export default function DecidronSimulator() {
16
+ const [network, setNetwork] = useState(null);
17
+ const [commands, setCommands] = useState([]);
18
+ const [stats, setStats] = useState(null);
19
+ const [history, setHistory] = useState([]);
20
+ const [results, setResults] = useState([]);
21
+ const [pending, setPending] = useState([]);
22
+ const [selected, setSelected] = useState(null); // null = live network
23
+ const [running, setRunning] = useState(false);
24
+ const [error, setError] = useState(null);
25
+
26
+ const refreshAll = useCallback(async () => {
27
+ try {
28
+ const [net, cmds, st, hist] = await Promise.all([
29
+ getNetwork(), getCommands(), getStats(), getHistory(),
30
+ ]);
31
+ setNetwork(net);
32
+ setCommands(cmds);
33
+ setStats(st);
34
+ setHistory(hist.snapshots);
35
+ } catch (err) {
36
+ setError(err.message);
37
+ }
38
+ }, []);
39
+
40
+ useEffect(() => { refreshAll(); }, [refreshAll]);
41
+
42
+ const handleQueue = async (channel, value) => {
43
+ setError(null);
44
+ try {
45
+ await submitSensorInput(channel, value);
46
+ setPending((prev) => [...prev, { channel, value }]);
47
+ } catch (err) {
48
+ setError(err.message);
49
+ }
50
+ };
51
+
52
+ const handleRun = async () => {
53
+ setRunning(true);
54
+ setError(null);
55
+ try {
56
+ const resp = await runSimulation();
57
+ setResults(resp.results);
58
+ setNetwork(resp.network);
59
+ setSelected(null);
60
+ setPending([]);
61
+ const [st, hist] = await Promise.all([getStats(), getHistory()]);
62
+ setStats(st);
63
+ setHistory(hist.snapshots);
64
+ } catch (err) {
65
+ setError(err.message);
66
+ } finally {
67
+ setRunning(false);
68
+ }
69
+ };
70
+
71
+ const handleCreate = async (cmd) => {
72
+ await createCommand(cmd);
73
+ setCommands(await getCommands());
74
+ };
75
+
76
+ const handleReset = async () => {
77
+ setError(null);
78
+ try {
79
+ await resetSimulation();
80
+ setResults([]);
81
+ setPending([]);
82
+ setSelected(null);
83
+ await refreshAll();
84
+ } catch (err) {
85
+ setError(err.message);
86
+ }
87
+ };
88
+
89
+ if (!network) {
90
+ return (
91
+ <div className="decidron-app">
92
+ {error ? <div className="decidron-error">{error}</div> : <p>Loading…</p>}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ const sensors = network.nodes.filter((n) => n.type === 'sensor');
98
+ const shownDiagram = selected ? selected.diagram : network.diagram;
99
+ const shownVersion = selected ? selected.version : network.version;
100
+
101
+ return (
102
+ <div className="decidron-app">
103
+ {error && <div className="decidron-error">{error}</div>}
104
+ <div className="decidron-grid">
105
+ <div>
106
+ <div className="decidron-panel">
107
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
108
+ <h2>Decidron Network (v{shownVersion}{selected ? ' — historical' : ' — live'})</h2>
109
+ <button type="button" className="decidron-btn secondary" onClick={handleReset}>
110
+ <RotateCcw size={13} style={{ marginRight: 4, verticalAlign: 'middle' }} />
111
+ Reset
112
+ </button>
113
+ </div>
114
+ <p className="panel-hint">
115
+ Sensors (blue) feed coupled Decidrons (purple) that drive
116
+ actuators (green). Commands can modify this topology.
117
+ </p>
118
+ <NetworkDiagram data={shownDiagram} />
119
+ </div>
120
+ <HistoryTimeline
121
+ snapshots={history}
122
+ selectedVersion={selected ? selected.version : null}
123
+ onSelect={(s) => setSelected(
124
+ s.version === network.version ? null : s
125
+ )}
126
+ />
127
+ </div>
128
+
129
+ <div>
130
+ <SensorInputForm
131
+ sensors={sensors}
132
+ pending={pending}
133
+ onQueue={handleQueue}
134
+ onRun={handleRun}
135
+ running={running}
136
+ />
137
+ <CommandBuilder
138
+ commands={commands}
139
+ sensors={sensors}
140
+ nodes={network.nodes}
141
+ onCreate={handleCreate}
142
+ />
143
+ <ResultsPanel results={results} />
144
+ <StatsTable stats={stats} />
145
+ </div>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
frontend/src/components/decidron/HistoryTimeline.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function HistoryTimeline({ snapshots, selectedVersion, onSelect }) {
4
+ return (
5
+ <div className="decidron-panel">
6
+ <h2>Network Modification History</h2>
7
+ <p className="panel-hint">
8
+ Each topology change is snapshotted. Select a version to view that
9
+ network state in the diagram above.
10
+ </p>
11
+ {(!snapshots || snapshots.length === 0) ? (
12
+ <p className="panel-hint">No snapshots yet.</p>
13
+ ) : (
14
+ snapshots.map((s) => (
15
+ <div
16
+ key={s.version}
17
+ className={`decidron-history-item ${s.version === selectedVersion ? 'active' : ''}`}
18
+ onClick={() => onSelect(s)}
19
+ >
20
+ <strong>v{s.version}</strong> — {s.reason}
21
+ <span style={{ float: 'right', opacity: 0.6 }}>
22
+ {new Date(s.timestamp * 1000).toLocaleTimeString()}
23
+ </span>
24
+ </div>
25
+ ))
26
+ )}
27
+ </div>
28
+ );
29
+ }
frontend/src/components/decidron/NetworkDiagram.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Network } from 'vis-network/standalone';
3
+
4
+ const OPTIONS = {
5
+ autoResize: true,
6
+ nodes: {
7
+ borderWidth: 2,
8
+ font: { color: '#ffffff', size: 14 },
9
+ },
10
+ edges: {
11
+ arrows: 'to',
12
+ color: { color: '#9aa3b2', highlight: '#6366F1' },
13
+ font: { size: 10, color: '#9aa3b2', strokeWidth: 0, align: 'middle' },
14
+ smooth: { type: 'cubicBezier', roundness: 0.4 },
15
+ },
16
+ groups: {
17
+ sensor: { shape: 'box', color: { background: '#3b82f6', border: '#1d4ed8' } },
18
+ decidron: { shape: 'hexagon', color: { background: '#8b5cf6', border: '#6d28d9' } },
19
+ actuator: { shape: 'triangle', color: { background: '#10b981', border: '#047857' } },
20
+ },
21
+ physics: {
22
+ stabilization: { iterations: 150 },
23
+ barnesHut: { springLength: 150, gravitationalConstant: -6000 },
24
+ },
25
+ interaction: { hover: true, tooltipDelay: 120 },
26
+ };
27
+
28
+ export default function NetworkDiagram({ data, height = 380 }) {
29
+ const containerRef = useRef(null);
30
+ const networkRef = useRef(null);
31
+
32
+ useEffect(() => {
33
+ if (!containerRef.current) return undefined;
34
+ networkRef.current = new Network(
35
+ containerRef.current,
36
+ data || { nodes: [], edges: [] },
37
+ OPTIONS,
38
+ );
39
+ return () => {
40
+ if (networkRef.current) {
41
+ networkRef.current.destroy();
42
+ networkRef.current = null;
43
+ }
44
+ };
45
+ }, [data]);
46
+
47
+ return (
48
+ <div
49
+ ref={containerRef}
50
+ className="decidron-diagram"
51
+ style={{ height, width: '100%' }}
52
+ />
53
+ );
54
+ }
frontend/src/components/decidron/ResultsPanel.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function ResultsPanel({ results }) {
4
+ return (
5
+ <div className="decidron-panel">
6
+ <h2>Latest Run Results</h2>
7
+ {(!results || results.length === 0) ? (
8
+ <p className="panel-hint">No runs yet. Queue a sensor input and run the simulation.</p>
9
+ ) : (
10
+ <table className="decidron-table">
11
+ <thead>
12
+ <tr>
13
+ <th>Command</th>
14
+ <th>Sensor</th>
15
+ <th>Value</th>
16
+ <th>Result</th>
17
+ <th>Output</th>
18
+ <th>Net ops</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {results.map((r, i) => (
23
+ <tr key={i}>
24
+ <td>{r.command}</td>
25
+ <td>{r.sensor}</td>
26
+ <td>{r.value}</td>
27
+ <td>
28
+ <span className={`decidron-chip ${r.matched ? 'match' : 'nomatch'}`}>
29
+ {r.matched ? 'matched' : 'no match'}
30
+ </span>
31
+ </td>
32
+ <td>{r.output || '—'}{r.actuator ? ` → ${r.actuator}` : ''}</td>
33
+ <td>{r.network_ops_applied}</td>
34
+ </tr>
35
+ ))}
36
+ </tbody>
37
+ </table>
38
+ )}
39
+ </div>
40
+ );
41
+ }
frontend/src/components/decidron/SensorInputForm.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Activity, Play } from 'lucide-react';
3
+
4
+ export default function SensorInputForm({ sensors, pending, onQueue, onRun, running }) {
5
+ const [channel, setChannel] = useState('');
6
+ const [value, setValue] = useState('');
7
+
8
+ useEffect(() => {
9
+ if (!channel && sensors.length) setChannel(sensors[0].id);
10
+ }, [sensors, channel]);
11
+
12
+ const handleQueue = (e) => {
13
+ e.preventDefault();
14
+ if (!channel || value === '') return;
15
+ onQueue(channel, value);
16
+ setValue('');
17
+ };
18
+
19
+ return (
20
+ <div className="decidron-panel">
21
+ <h2>Sensor Input</h2>
22
+ <p className="panel-hint">
23
+ Simulate a sensor reading, then run the network to process queued
24
+ inputs against your processing commands.
25
+ </p>
26
+ <form onSubmit={handleQueue}>
27
+ <div className="decidron-row">
28
+ <div className="decidron-field" style={{ flex: 1 }}>
29
+ <label>Channel</label>
30
+ <select value={channel} onChange={(e) => setChannel(e.target.value)}>
31
+ {sensors.map((s) => (
32
+ <option key={s.id} value={s.id}>{s.label}</option>
33
+ ))}
34
+ </select>
35
+ </div>
36
+ <div className="decidron-field" style={{ flex: 1 }}>
37
+ <label>Value</label>
38
+ <input
39
+ type="text"
40
+ value={value}
41
+ placeholder="e.g. 97"
42
+ onChange={(e) => setValue(e.target.value)}
43
+ />
44
+ </div>
45
+ <button type="submit" className="decidron-btn secondary">
46
+ <Activity size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
47
+ Queue
48
+ </button>
49
+ </div>
50
+ </form>
51
+
52
+ {pending.length > 0 && (
53
+ <ul className="decidron-pending">
54
+ {pending.map((p, i) => (
55
+ <li key={i}>{p.channel}: {p.value}</li>
56
+ ))}
57
+ </ul>
58
+ )}
59
+
60
+ <div className="decidron-row" style={{ marginTop: '0.5rem' }}>
61
+ <button
62
+ type="button"
63
+ className="decidron-btn"
64
+ onClick={onRun}
65
+ disabled={running || pending.length === 0}
66
+ >
67
+ <Play size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
68
+ {running ? 'Running…' : `Run Simulation (${pending.length})`}
69
+ </button>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
frontend/src/components/decidron/StatsTable.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ function Section({ title, data }) {
4
+ const rows = Object.entries(data || {});
5
+ if (rows.length === 0) return null;
6
+ return (
7
+ <>
8
+ <tr><th colSpan={4} style={{ paddingTop: '0.6rem' }}>{title}</th></tr>
9
+ {rows.map(([key, s]) => (
10
+ <tr key={title + key}>
11
+ <td>{key}</td>
12
+ <td>{s.count}</td>
13
+ <td>{s.matches}</td>
14
+ <td>{s.avg_latency_ms != null ? s.avg_latency_ms.toFixed(3) : '—'}</td>
15
+ </tr>
16
+ ))}
17
+ </>
18
+ );
19
+ }
20
+
21
+ export default function StatsTable({ stats }) {
22
+ const empty = !stats || stats.total_runs === 0;
23
+ return (
24
+ <div className="decidron-panel">
25
+ <h2>Performance Statistics</h2>
26
+ {empty ? (
27
+ <p className="panel-hint">Run the simulation to collect statistics.</p>
28
+ ) : (
29
+ <>
30
+ <p className="panel-hint">
31
+ {stats.total_runs} command evaluation(s), {stats.total_matches} match(es).
32
+ </p>
33
+ <table className="decidron-table">
34
+ <thead>
35
+ <tr><th>Key</th><th>Runs</th><th>Matches</th><th>Avg ms</th></tr>
36
+ </thead>
37
+ <tbody>
38
+ <Section title="Per Sensor" data={stats.per_sensor} />
39
+ <Section title="Per Command" data={stats.per_command} />
40
+ <Section title="Per Output" data={stats.per_output} />
41
+ </tbody>
42
+ </table>
43
+ </>
44
+ )}
45
+ </div>
46
+ );
47
+ }
frontend/src/styles/decidron.css ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .decidron-app {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 1rem 1.25rem 3rem;
5
+ }
6
+
7
+ .decidron-grid {
8
+ display: grid;
9
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
10
+ gap: 1rem;
11
+ }
12
+
13
+ @media (max-width: 980px) {
14
+ .decidron-grid { grid-template-columns: 1fr; }
15
+ }
16
+
17
+ .decidron-panel {
18
+ background: var(--card-bg);
19
+ border: 1px solid var(--border-primary);
20
+ border-radius: 12px;
21
+ box-shadow: var(--shadow-sm);
22
+ padding: 1rem;
23
+ margin-bottom: 1rem;
24
+ }
25
+
26
+ .decidron-panel h2 {
27
+ font-size: 0.95rem;
28
+ font-weight: 700;
29
+ color: var(--text-primary);
30
+ margin-bottom: 0.25rem;
31
+ }
32
+
33
+ .decidron-panel .panel-hint {
34
+ font-size: 0.78rem;
35
+ color: var(--text-tertiary);
36
+ margin-bottom: 0.75rem;
37
+ }
38
+
39
+ .decidron-diagram {
40
+ background: var(--bg-secondary);
41
+ border: 1px solid var(--border-primary);
42
+ border-radius: 10px;
43
+ }
44
+
45
+ .decidron-field {
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 0.25rem;
49
+ margin-bottom: 0.6rem;
50
+ }
51
+
52
+ .decidron-field label {
53
+ font-size: 0.75rem;
54
+ font-weight: 600;
55
+ color: var(--text-secondary);
56
+ }
57
+
58
+ .decidron-field input,
59
+ .decidron-field select {
60
+ padding: 0.45rem 0.55rem;
61
+ border: 1px solid var(--border-primary);
62
+ border-radius: 8px;
63
+ background: var(--bg-primary);
64
+ color: var(--text-primary);
65
+ font-size: 0.85rem;
66
+ }
67
+
68
+ .decidron-row {
69
+ display: flex;
70
+ gap: 0.5rem;
71
+ flex-wrap: wrap;
72
+ align-items: flex-end;
73
+ }
74
+
75
+ .decidron-btn {
76
+ padding: 0.5rem 0.9rem;
77
+ border: none;
78
+ border-radius: 8px;
79
+ background: var(--accent-gradient);
80
+ color: #fff;
81
+ font-size: 0.85rem;
82
+ font-weight: 600;
83
+ }
84
+
85
+ .decidron-btn:disabled { opacity: 0.5; cursor: not-allowed; }
86
+
87
+ .decidron-btn.secondary {
88
+ background: transparent;
89
+ color: var(--text-secondary);
90
+ border: 1px solid var(--border-primary);
91
+ }
92
+
93
+ .decidron-table {
94
+ width: 100%;
95
+ border-collapse: collapse;
96
+ font-size: 0.8rem;
97
+ }
98
+
99
+ .decidron-table th,
100
+ .decidron-table td {
101
+ text-align: left;
102
+ padding: 0.4rem 0.5rem;
103
+ border-bottom: 1px solid var(--border-muted);
104
+ color: var(--text-secondary);
105
+ }
106
+
107
+ .decidron-table th { color: var(--text-tertiary); font-weight: 600; }
108
+
109
+ .decidron-chip {
110
+ display: inline-block;
111
+ padding: 0.1rem 0.45rem;
112
+ border-radius: 999px;
113
+ font-size: 0.7rem;
114
+ font-weight: 600;
115
+ background: var(--accent-light);
116
+ color: var(--accent-primary);
117
+ }
118
+
119
+ .decidron-chip.match { background: var(--speaker-b-bg); color: var(--speaker-b-color); }
120
+ .decidron-chip.nomatch { background: var(--bg-tertiary); color: var(--text-tertiary); }
121
+
122
+ .decidron-pending {
123
+ list-style: none;
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ gap: 0.35rem;
127
+ margin: 0.4rem 0;
128
+ }
129
+
130
+ .decidron-pending li {
131
+ font-size: 0.75rem;
132
+ padding: 0.15rem 0.5rem;
133
+ border-radius: 6px;
134
+ background: var(--comp-bg);
135
+ border: 1px solid var(--comp-border);
136
+ color: var(--text-secondary);
137
+ }
138
+
139
+ .decidron-history-item {
140
+ padding: 0.5rem 0.6rem;
141
+ border: 1px solid var(--border-primary);
142
+ border-radius: 8px;
143
+ margin-bottom: 0.4rem;
144
+ cursor: pointer;
145
+ font-size: 0.8rem;
146
+ color: var(--text-secondary);
147
+ }
148
+
149
+ .decidron-history-item.active {
150
+ border-color: var(--accent-primary);
151
+ background: var(--accent-light);
152
+ }
153
+
154
+ .decidron-error {
155
+ background: var(--error-bg);
156
+ border: 1px solid var(--error-border);
157
+ color: var(--error-text);
158
+ padding: 0.5rem 0.75rem;
159
+ border-radius: 8px;
160
+ font-size: 0.82rem;
161
+ margin-bottom: 0.75rem;
162
+ }
163
+
164
+ .decidron-cmd {
165
+ border: 1px solid var(--border-primary);
166
+ border-radius: 8px;
167
+ padding: 0.5rem 0.6rem;
168
+ margin-bottom: 0.4rem;
169
+ font-size: 0.8rem;
170
+ color: var(--text-secondary);
171
+ }
172
+
173
+ .decidron-cmd code {
174
+ background: var(--bg-tertiary);
175
+ padding: 0.05rem 0.3rem;
176
+ border-radius: 4px;
177
+ font-size: 0.75rem;
178
+ }
frontend/src/utils/api.js CHANGED
@@ -1,385 +1,62 @@
1
- // Default to '' (relative URLs) so any production-style build - whether
2
- // done inside Docker (Dockerfile sets REACT_APP_API_URL=) or on the host
3
- // without that env var - hits the same origin that served the page. This
4
- // avoids the cross-origin trap where a `npm run build` on the host
5
- // without REACT_APP_API_URL would silently bake `http://localhost:8000`
6
- // into the bundle and break every API call from a Docker deployment.
7
  //
8
- // If you want the CRA dev server (`npm start` on :3000) to talk to a
9
- // FastAPI backend on :8000, set REACT_APP_API_URL=http://localhost:8000
10
- // in `frontend/.env.development` or your shell.
11
  const API_BASE = process.env.REACT_APP_API_URL !== undefined
12
  ? process.env.REACT_APP_API_URL
13
  : '';
14
 
15
- export async function fetchModels() {
16
- const resp = await fetch(`${API_BASE}/api/models`, { cache: 'no-store' });
17
- if (!resp.ok) throw new Error(`Failed to fetch models: ${resp.status}`);
18
- return resp.json();
19
- }
20
-
21
- export async function fetchPersonas() {
22
- const resp = await fetch(`${API_BASE}/api/personas`, { cache: 'no-store' });
23
- if (!resp.ok) throw new Error(`Failed to fetch personas: ${resp.status}`);
24
- return resp.json();
25
- }
26
-
27
- export async function fetchDemoQuestions() {
28
- const resp = await fetch(`${API_BASE}/api/demo-questions`, { cache: 'no-store' });
29
- if (!resp.ok) throw new Error(`Failed to fetch demo questions: ${resp.status}`);
30
- return resp.json();
31
- }
32
-
33
- export async function generateRole({ model_id, name, profile, identity, samples, role_style }) {
34
- const resp = await fetch(`${API_BASE}/api/chat/generate-role`, {
35
- method: 'POST',
36
  headers: { 'Content-Type': 'application/json' },
37
- body: JSON.stringify({ model_id, name, profile, identity, samples, role_style }),
 
38
  });
39
  if (!resp.ok) {
40
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
41
- throw new Error(err.detail || 'Role generation failed');
42
- }
43
- return resp.json();
44
- }
45
-
46
- export async function generateRoleFreeform({ model_id, name, text, role_style }) {
47
- const resp = await fetch(`${API_BASE}/api/chat/generate-role-freeform`, {
48
- method: 'POST',
49
- headers: { 'Content-Type': 'application/json' },
50
- body: JSON.stringify({ model_id, name, text, role_style }),
51
- });
52
- if (!resp.ok) {
53
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
54
- throw new Error(err.detail || 'Role generation failed');
55
- }
56
- return resp.json();
57
- }
58
-
59
- /**
60
- * Start a CCAI conversation and consume the SSE stream.
61
- *
62
- * Body: { question, participants[], expert_personas[], model_assignments,
63
- * orchestrator_model_id, summarizer_model_id, max_participants }
64
- */
65
- export async function startChat(body, handlers, abortSignal) {
66
- const resp = await fetch(`${API_BASE}/api/chat/start`, {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify(body),
70
- signal: abortSignal,
71
- });
72
-
73
- if (!resp.ok) {
74
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
75
- throw new Error(err.detail || 'Chat start failed');
76
  }
77
-
78
- const reader = resp.body.getReader();
79
- const decoder = new TextDecoder();
80
- let buffer = '';
81
-
82
- try {
83
- while (true) {
84
- if (abortSignal?.aborted) break;
85
- const { value, done } = await reader.read();
86
- if (done) break;
87
- buffer += decoder.decode(value, { stream: true });
88
-
89
- const parts = buffer.split('\n\n');
90
- buffer = parts.pop();
91
-
92
- for (const part of parts) {
93
- const lines = part.trim().split('\n');
94
- let eventType = 'message';
95
- let data = '';
96
- for (const line of lines) {
97
- if (line.startsWith('event: ')) eventType = line.slice(7).trim();
98
- else if (line.startsWith('data: ')) data = line.slice(6);
99
- }
100
- if (!data) continue;
101
- try {
102
- const parsed = JSON.parse(data);
103
- const handler = handlers[eventHandlerKey(eventType)];
104
- if (handler) handler(parsed);
105
- } catch (e) {
106
- console.warn('SSE parse error', e, data);
107
- }
108
- }
109
- }
110
- } finally {
111
- reader.releaseLock();
112
- }
113
- handlers.onDone?.();
114
- }
115
-
116
- function eventHandlerKey(eventType) {
117
- switch (eventType) {
118
- case 'session': return 'onSession';
119
- case 'message': return 'onMessage';
120
- case 'message_stream_start': return 'onMessageStreamStart';
121
- case 'message_delta': return 'onMessageDelta';
122
- case 'orchestrator': return 'onOrchestrator';
123
- case 'system': return 'onSystem';
124
- case 'status': return 'onStatus';
125
- case 'error': return 'onError';
126
- case 'done': return 'onDone';
127
- case 'failsafe_pause': return 'onFailsafePause';
128
- case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
129
- case 'participant_error': return 'onParticipantError';
130
- case 'participant_substituted': return 'onParticipantSubstituted';
131
- case 'participant_replaced': return 'onParticipantReplaced';
132
- case 'vote_cast': return 'onVoteCast';
133
- case 'vote_tally': return 'onVoteTally';
134
- case 'credentials_updated': return 'onCredentialsUpdated';
135
- case 'human_turn_needed': return 'onHumanTurnNeeded';
136
- case 'human_turn_cleared': return 'onHumanTurnCleared';
137
- default: return null;
138
- }
139
- }
140
-
141
- export async function continueChat(sessionId, reason) {
142
- const resp = await fetch(
143
- `${API_BASE}/api/chat/${sessionId}/continue?reason=${encodeURIComponent(reason)}`,
144
- { method: 'POST' },
145
- );
146
- if (!resp.ok) throw new Error('Continue failed');
147
  return resp.json();
148
  }
149
 
150
- export async function getOrchestrator() {
151
- const resp = await fetch(`${API_BASE}/api/chat/orchestrator`);
152
- if (!resp.ok) throw new Error('Failed to get orchestrator');
153
- return resp.json();
154
  }
155
 
156
- export async function setOrchestrator(modelId) {
157
- const resp = await fetch(`${API_BASE}/api/chat/orchestrator`, {
158
- method: 'PUT',
159
- headers: { 'Content-Type': 'application/json' },
160
- body: JSON.stringify({ model_id: modelId }),
161
- });
162
- if (!resp.ok) throw new Error('Failed to set orchestrator');
163
- return resp.json();
164
  }
165
 
166
- export async function getSpeedPriority() {
167
- const resp = await fetch(`${API_BASE}/api/chat/speed-priority`);
168
- if (!resp.ok) throw new Error('Failed to get speed priority');
169
- return resp.json();
170
- }
171
-
172
- export async function setSpeedPriority(enabled) {
173
- const resp = await fetch(`${API_BASE}/api/chat/speed-priority`, {
174
- method: 'PUT',
175
- headers: { 'Content-Type': 'application/json' },
176
- body: JSON.stringify({ enabled }),
177
- });
178
- if (!resp.ok) throw new Error('Failed to set speed priority');
179
- return resp.json();
180
- }
181
-
182
- /**
183
- * Fetch the catalog of available conversation structures and
184
- * decision-making methods. The Settings menu's "Conversation format"
185
- * accordion populates from this so adding a new plugin server-side
186
- * doesn't require frontend code changes.
187
- *
188
- * Returns: { structures: [{id, name, description}, ...],
189
- * decisions: [{id, name, description}, ...],
190
- * default_structure_id, default_decision_id }
191
- */
192
- export async function fetchConversationFormats() {
193
- const resp = await fetch(`${API_BASE}/api/chat/conversation-formats`, { cache: 'no-store' });
194
- if (!resp.ok) throw new Error('Failed to fetch conversation formats');
195
- return resp.json();
196
- }
197
-
198
- export async function exportChat(sessionId, fmt = 'txt') {
199
- const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/export?fmt=${fmt}`);
200
- if (!resp.ok) throw new Error('Export failed');
201
- return resp.json();
202
- }
203
-
204
- export async function exportApiLog(sessionId) {
205
- const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/api-log`);
206
- if (!resp.ok) throw new Error('API log export failed');
207
- return resp.json();
208
- }
209
-
210
- export async function fetchTableView(sessionId) {
211
- const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/table`);
212
- if (!resp.ok) throw new Error('Table view fetch failed');
213
- return resp.json();
214
- }
215
-
216
- /**
217
- * Fetch the catalog of every prompt template the orchestrator and
218
- * participants use, grouped by phase and annotated with purpose and
219
- * runtime variables. Backs the "View current chat prompts" modal.
220
- */
221
- export async function fetchPromptCatalog() {
222
- const resp = await fetch(`${API_BASE}/api/chat/prompts/catalog`, { cache: 'no-store' });
223
- if (!resp.ok) throw new Error('Failed to fetch prompt catalog');
224
- return resp.json();
225
- }
226
-
227
- /**
228
- * Ask the backend to pick the top `count` participants from the
229
- * candidate pool by relevance to the question. Used by the
230
- * "Select N Automatically" toggle in the participants dropdown.
231
- *
232
- * Returns: { selected: [participant_id, ...], rationale: "..." }
233
- */
234
- export async function autoSelectParticipants({ question, count, candidates, orchestrator_model_id }) {
235
- const resp = await fetch(`${API_BASE}/api/chat/auto-select-participants`, {
236
  method: 'POST',
237
- headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify({ question, count, candidates, orchestrator_model_id }),
239
  });
240
- if (!resp.ok) {
241
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
242
- throw new Error(err.detail || 'Auto-select failed');
243
- }
244
- return resp.json();
245
  }
246
 
247
- /**
248
- * Fetch the user-tunable conversation-limit defaults, bounds, and
249
- * descriptions. The frontend uses this to render the "Conversation
250
- * limits" settings modal entirely from the server schema, so adding
251
- * a new knob in the backend doesn't require a frontend change.
252
- *
253
- * Shape: { defaults: {field: int}, bounds: {field: {min, max}},
254
- * descriptions: {field: {group, label, help}} }
255
- */
256
- export async function fetchConversationLimitsDefaults() {
257
- const resp = await fetch(`${API_BASE}/api/chat/limits/defaults`, { cache: 'no-store' });
258
- if (!resp.ok) throw new Error('Failed to fetch conversation-limit defaults');
259
- return resp.json();
260
- }
261
-
262
- export async function fetchCredentials(sessionId) {
263
- const resp = await fetch(
264
- `${API_BASE}/api/chat/${sessionId}/credentials`,
265
- { cache: 'no-store' },
266
- );
267
- if (!resp.ok) throw new Error('Credentials fetch failed');
268
- return resp.json();
269
- }
270
-
271
- /**
272
- * Submit the human participant's response to the orchestrator for the
273
- * currently pending turn. `skip=true` flips the turn into a "declined
274
- * to comment" note rather than a message.
275
- */
276
- export async function submitHumanResponse(sessionId, { text, skip = false } = {}) {
277
- const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/human-response`, {
278
  method: 'POST',
279
- headers: { 'Content-Type': 'application/json' },
280
- body: JSON.stringify({ text: text || '', skip: !!skip }),
281
  });
282
- if (!resp.ok) {
283
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
284
- throw new Error(err.detail || 'Submit failed');
285
- }
286
- return resp.json();
287
  }
288
 
289
- /**
290
- * Patch the in-the-loop human's credential summary. Used by the
291
- * CredentialSummaryModal's edit affordance on the human's row. The
292
- * backend rejects fields it doesn't know about; we send only the
293
- * fields the user actually changed (sparse patch).
294
- */
295
- export async function patchHumanCredential(sessionId, patch) {
296
- const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/credentials/human`, {
297
- method: 'PATCH',
298
- headers: { 'Content-Type': 'application/json' },
299
- body: JSON.stringify(patch),
300
- });
301
- if (!resp.ok) {
302
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
303
- throw new Error(err.detail || 'Edit failed');
304
- }
305
- return resp.json();
306
- }
307
-
308
- /**
309
- * Start the AI-assisted credential intake Q&A flow. Returns either a
310
- * first question or (rarely) a final summary if the LLM bails. The
311
- * draft_id is needed for subsequent /answer calls.
312
- */
313
- /**
314
- * Generate a structured credential summary from a human's freeform
315
- * profile text (experience, personality, etc.). Uses the orchestrator
316
- * the same way it assesses an LLM participant's role prompt.
317
- */
318
- export async function generateHumanCredentialFromProfile({
319
- name, question, profile_text, participant_id, orchestrator_model_id,
320
- }) {
321
- const resp = await fetch(`${API_BASE}/api/chat/credentials/from-profile`, {
322
- method: 'POST',
323
- headers: { 'Content-Type': 'application/json' },
324
- body: JSON.stringify({
325
- name,
326
- question,
327
- profile_text,
328
- participant_id: participant_id || '',
329
- orchestrator_model_id: orchestrator_model_id || null,
330
- }),
331
- });
332
- if (!resp.ok) {
333
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
334
- throw new Error(err.detail || 'Credential generation failed');
335
- }
336
- return resp.json();
337
- }
338
-
339
- export async function startCredentialDraft({
340
- name, question, max_questions = 6, orchestrator_model_id = null,
341
- }) {
342
- const resp = await fetch(`${API_BASE}/api/chat/credentials/draft`, {
343
- method: 'POST',
344
- headers: { 'Content-Type': 'application/json' },
345
- body: JSON.stringify({ name, question, max_questions, orchestrator_model_id }),
346
- });
347
- if (!resp.ok) {
348
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
349
- throw new Error(err.detail || 'Credential draft start failed');
350
- }
351
- return resp.json();
352
- }
353
-
354
- export async function answerCredentialDraft(draftId, answer) {
355
- const resp = await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}/answer`, {
356
  method: 'POST',
357
- headers: { 'Content-Type': 'application/json' },
358
- body: JSON.stringify({ answer: answer || '' }),
359
  });
360
- if (!resp.ok) {
361
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
362
- throw new Error(err.detail || 'Credential draft answer failed');
363
- }
364
- return resp.json();
365
  }
366
 
367
- export async function cancelCredentialDraft(draftId) {
368
- try {
369
- await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}`, {
370
- method: 'DELETE',
371
- });
372
- } catch (_) { /* fire-and-forget cleanup; ignore */ }
373
  }
374
 
375
- export async function getAuthStatus() {
376
- const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
377
- if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
378
- return resp.json();
379
  }
380
 
381
- export async function getRateLimitStatus() {
382
- const resp = await fetch(`${API_BASE}/api/rate-limit/status`, { credentials: 'include' });
383
- if (!resp.ok) return { remaining: -1, daily_limit: 30 };
384
- return resp.json();
385
  }
 
1
+ // API client for the Decidron network simulator.
 
 
 
 
 
2
  //
3
+ // Defaults to relative URLs so a production build served by the FastAPI
4
+ // container hits the same origin. For CRA dev (`npm start` on :3000),
5
+ // set REACT_APP_API_URL=http://localhost:7860 in frontend/.env.development.
6
  const API_BASE = process.env.REACT_APP_API_URL !== undefined
7
  ? process.env.REACT_APP_API_URL
8
  : '';
9
 
10
+ async function jsonFetch(path, options = {}) {
11
+ const resp = await fetch(`${API_BASE}${path}`, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  headers: { 'Content-Type': 'application/json' },
13
+ cache: 'no-store',
14
+ ...options,
15
  });
16
  if (!resp.ok) {
17
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
18
+ throw new Error(err.detail || `Request failed: ${resp.status}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  return resp.json();
21
  }
22
 
23
+ export function getNetwork() {
24
+ return jsonFetch('/api/decidron/network');
 
 
25
  }
26
 
27
+ export function getCommands() {
28
+ return jsonFetch('/api/decidron/commands');
 
 
 
 
 
 
29
  }
30
 
31
+ export function createCommand(command) {
32
+ return jsonFetch('/api/decidron/commands', {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  method: 'POST',
34
+ body: JSON.stringify(command),
 
35
  });
 
 
 
 
 
36
  }
37
 
38
+ export function submitSensorInput(channel, value) {
39
+ return jsonFetch('/api/decidron/sensors/input', {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  method: 'POST',
41
+ body: JSON.stringify({ channel, value }),
 
42
  });
 
 
 
 
 
43
  }
44
 
45
+ export function runSimulation(inputs = null) {
46
+ return jsonFetch('/api/decidron/run', {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  method: 'POST',
48
+ body: JSON.stringify(inputs ? { inputs } : {}),
 
49
  });
 
 
 
 
 
50
  }
51
 
52
+ export function getStats() {
53
+ return jsonFetch('/api/decidron/stats');
 
 
 
 
54
  }
55
 
56
+ export function getHistory() {
57
+ return jsonFetch('/api/decidron/history');
 
 
58
  }
59
 
60
+ export function resetSimulation() {
61
+ return jsonFetch('/api/decidron/reset', { method: 'POST' });
 
 
62
  }
frontend/src/utils/storage.js DELETED
@@ -1,135 +0,0 @@
1
- /**
2
- * Tiny localStorage shim for the CCAI demo.
3
- *
4
- * Single namespace `ccai-vibe-demo` so we never collide with anything else
5
- * the host page is doing. Schema-versioned so we can migrate if/when the
6
- * shape changes.
7
- */
8
-
9
- const NS = 'ccai-vibe-demo';
10
- const SCHEMA_VERSION = 1;
11
-
12
- const DEFAULTS = {
13
- schema_version: SCHEMA_VERSION,
14
- expert_personas: [],
15
- participants_selected: [],
16
- participants_enabled: {},
17
- model_assignments: {},
18
- orchestrator_model_id: null,
19
- summarizer_model_id: null,
20
- max_participants: 5,
21
- theme: null,
22
- // Sparse map of user overrides to ConversationLimits fields. Empty
23
- // map = "use server defaults". The server clamps anything we send,
24
- // so storing whatever the user typed (including stale values from
25
- // an earlier session) is safe.
26
- conversation_limits: {},
27
- // When true, the participants dropdown shows "Select N Automatically"
28
- // as the active mode and the per-persona checkboxes are hidden.
29
- // The auto-select happens just before /chat/start.
30
- auto_select_mode: false,
31
- // In-the-loop human participant. null when no human is configured.
32
- // Shape when set:
33
- // { participant_id, name, profile_text,
34
- // credential_summary: {
35
- // name, expertise, personality,
36
- // credibility_for_question, bias_to_watch
37
- // } }
38
- // Persisted across page reloads so the user doesn't have to re-author
39
- // their summary every session. Cleared from storage when the user
40
- // removes the human via the sidebar.
41
- human_participant: null,
42
- // Conversation-format plugin choices (see backend
43
- // /api/chat/conversation-formats). null means "use server default"
44
- // — the backend returns `default_structure_id` / `default_decision_id`
45
- // alongside the catalog so the UI can highlight them.
46
- conversation_structure_id: null,
47
- decision_method_id: null,
48
- };
49
-
50
- function readAll() {
51
- try {
52
- const raw = window.localStorage.getItem(NS);
53
- if (!raw) return { ...DEFAULTS };
54
- const parsed = JSON.parse(raw);
55
- if (parsed.schema_version !== SCHEMA_VERSION) {
56
- // Future-proofing: migrate or wipe. For v1 we just merge with defaults.
57
- return { ...DEFAULTS, ...parsed, schema_version: SCHEMA_VERSION };
58
- }
59
- return { ...DEFAULTS, ...parsed };
60
- } catch (err) {
61
- console.warn('localStorage read failed:', err);
62
- return { ...DEFAULTS };
63
- }
64
- }
65
-
66
- function writeAll(state) {
67
- try {
68
- window.localStorage.setItem(NS, JSON.stringify(state));
69
- } catch (err) {
70
- console.warn('localStorage write failed:', err);
71
- }
72
- }
73
-
74
- export function loadState() {
75
- return readAll();
76
- }
77
-
78
- export function patchState(patch) {
79
- const current = readAll();
80
- const next = { ...current, ...patch };
81
- writeAll(next);
82
- return next;
83
- }
84
-
85
- export function setExpertPersonas(list) {
86
- return patchState({ expert_personas: list });
87
- }
88
-
89
- export function setParticipantsSelected(ids) {
90
- return patchState({ participants_selected: ids });
91
- }
92
-
93
- export function setParticipantsEnabled(map) {
94
- return patchState({ participants_enabled: map });
95
- }
96
-
97
- export function setModelAssignments(map) {
98
- return patchState({ model_assignments: map });
99
- }
100
-
101
- export function setOrchestratorModelId(modelId) {
102
- return patchState({ orchestrator_model_id: modelId });
103
- }
104
-
105
- export function setSummarizerModelId(modelId) {
106
- return patchState({ summarizer_model_id: modelId });
107
- }
108
-
109
- export function setMaxParticipants(n) {
110
- return patchState({ max_participants: n });
111
- }
112
-
113
- export function setTheme(theme) {
114
- return patchState({ theme });
115
- }
116
-
117
- export function setConversationLimits(limitsMap) {
118
- return patchState({ conversation_limits: limitsMap || {} });
119
- }
120
-
121
- export function setAutoSelectMode(on) {
122
- return patchState({ auto_select_mode: !!on });
123
- }
124
-
125
- export function setHumanParticipant(humanOrNull) {
126
- return patchState({ human_participant: humanOrNull || null });
127
- }
128
-
129
- export function setConversationStructureId(id) {
130
- return patchState({ conversation_structure_id: id || null });
131
- }
132
-
133
- export function setDecisionMethodId(id) {
134
- return patchState({ decision_method_id: id || null });
135
- }