Add /llms.txt API reference and POST /api/notebooks/compose endpoint

Machine-readable API docs at /llms.txt for LLM collaboration on circuit
design notebooks linked from Mims Electronics Reference Library.

Compose endpoint creates fully-populated notebooks in one call with
optional SPICE simulation. Per-cell try/except ensures partial simulation
failures don't lose the notebook.

Also extracts get_engine to spicebook.engine and makes
generate_notebook_id a public API.
This commit is contained in:
Ryan Malloy 2026-02-14 15:43:26 -07:00
parent c581786372
commit 1e08be4409
7 changed files with 675 additions and 13 deletions

View File

@ -0,0 +1,13 @@
"""SPICE simulation engine registry."""
from fastapi import HTTPException
from spicebook.engine.base import SpiceEngine
from spicebook.engine.ngspice import NgspiceEngine
def get_engine(engine_name: str) -> SpiceEngine:
"""Resolve a simulation engine by name."""
if engine_name == "ngspice":
return NgspiceEngine()
raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")

View File

@ -10,7 +10,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from spicebook.config import settings
from spicebook.routers import notebooks, schematics, simulation, waveforms
from spicebook.routers import compose, notebooks, schematics, simulation, waveforms
logger = logging.getLogger("spicebook")
@ -59,6 +59,9 @@ def create_app() -> FastAPI:
)
return response
# compose MUST be registered before notebooks so that
# POST /api/notebooks/compose matches before {notebook_id}
application.include_router(compose.router)
application.include_router(notebooks.router)
application.include_router(schematics.router)
application.include_router(simulation.router)

View File

@ -67,3 +67,16 @@ class UpdateCellRequest(BaseModel):
class ReorderCellsRequest(BaseModel):
cell_ids: list[str] # Ordered list of all cell IDs
class ComposeCellInput(BaseModel):
type: CellType
source: str = ""
class ComposeNotebookRequest(BaseModel):
title: str = Field("Untitled Notebook", max_length=256)
engine: str = "ngspice"
tags: list[str] = Field(default_factory=list, max_length=20)
cells: list[ComposeCellInput] = Field(..., max_length=100)
run: bool = False

View File

@ -0,0 +1,94 @@
"""Compose endpoint — create a fully-populated notebook in one call."""
import logging
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.engine import get_engine
from spicebook.models.notebook import (
Cell,
CellOutput,
CellType,
ComposeNotebookRequest,
Notebook,
NotebookMetadata,
)
from spicebook.models.simulation import SimulationResponse
from spicebook.storage.filesystem import generate_notebook_id, save_notebook
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/notebooks", tags=["compose"])
@router.post("/compose", status_code=201)
async def compose_notebook(req: ComposeNotebookRequest):
"""Create a notebook with pre-populated cells, optionally running SPICE cells."""
if not req.cells:
raise HTTPException(status_code=400, detail="At least one cell is required")
nb_id = generate_notebook_id(req.title)
now = datetime.now(timezone.utc).isoformat()
cells: list[Cell] = []
for item in req.cells:
cells.append(
Cell(
id=f"cell-{uuid.uuid4().hex[:12]}",
type=item.type,
source=item.source,
)
)
notebook = Notebook(
metadata=NotebookMetadata(
title=req.title,
engine=req.engine,
tags=req.tags,
created=now,
modified=now,
),
cells=cells,
)
if req.run:
engine = get_engine(req.engine)
for cell in notebook.cells:
if cell.type != CellType.SPICE:
continue
if not cell.source.strip():
continue
try:
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir))
except Exception:
logger.exception("Simulation failed for cell %s", cell.id)
result = SimulationResponse(
success=False,
error="Internal simulation error",
elapsed_seconds=0.0,
)
run_ts = datetime.now(timezone.utc).isoformat()
cell.outputs.append(
CellOutput(
output_type="simulation_result" if result.success else "error",
data={
"success": result.success,
"waveform": result.waveform.model_dump() if result.waveform else None,
"log": result.log,
"error": result.error,
"elapsed_seconds": result.elapsed_seconds,
},
timestamp=run_ts,
)
)
save_notebook(settings.notebook_dir, nb_id, notebook)
return {"id": nb_id, **notebook.model_dump()}

View File

@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.engine.ngspice import NgspiceEngine
from spicebook.engine import get_engine
from spicebook.models.notebook import CellOutput, CellType
from spicebook.models.simulation import SimulationRequest, SimulationResponse
from spicebook.storage.filesystem import load_notebook, save_notebook
@ -15,17 +15,10 @@ from spicebook.storage.filesystem import load_notebook, save_notebook
router = APIRouter(prefix="/api", tags=["simulation"])
def _get_engine(engine_name: str) -> NgspiceEngine:
"""Resolve engine by name. Only ngspice is supported in Phase 1."""
if engine_name == "ngspice":
return NgspiceEngine()
raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")
@router.post("/simulate", response_model=SimulationResponse)
async def simulate(req: SimulationRequest):
"""Run a standalone SPICE simulation."""
engine = _get_engine(req.engine)
engine = get_engine(req.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(req.netlist, Path(tmpdir))
@ -61,7 +54,7 @@ async def run_cell(notebook_id: str, cell_id: str):
if not cell.source.strip():
raise HTTPException(status_code=400, detail="Cell source is empty")
engine = _get_engine(nb.metadata.engine)
engine = get_engine(nb.metadata.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir))

View File

@ -33,7 +33,7 @@ def _slugify(text: str) -> str:
return slug or "notebook"
def _generate_notebook_id(title: str) -> str:
def generate_notebook_id(title: str) -> str:
"""Generate a notebook ID from title, with short UUID suffix for uniqueness."""
slug = _slugify(title)
short_uid = uuid.uuid4().hex[:8]
@ -105,7 +105,7 @@ def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None
def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]:
"""Create a new notebook and save it. Returns (notebook_id, notebook)."""
nb_id = _generate_notebook_id(title)
nb_id = generate_notebook_id(title)
now = _now_iso()
notebook = Notebook(

546
frontend/public/llms.txt Normal file
View File

@ -0,0 +1,546 @@
# SpiceBook
> Notebook interface for SPICE circuit simulation. Create, edit, and run SPICE netlists in a cell-based notebook UI with waveform visualization and schematic generation. Powered by ngspice.
Base URL: `https://spicebook.warehack.ing`
## API Reference
All endpoints accept and return JSON unless noted otherwise. Prefix all paths with the base URL.
---
### Notebooks
#### List notebooks
```
GET /api/notebooks
```
**Response** `200`
```json
[
{
"id": "rc-low-pass-a1b2c3d4",
"title": "RC Low-Pass Filter",
"engine": "ngspice",
"tags": ["filter", "rc"],
"cell_count": 3,
"modified": "2026-02-13T18:30:00+00:00"
}
]
```
#### Create notebook
```
POST /api/notebooks
```
**Request body**
```json
{
"title": "My Circuit",
"engine": "ngspice"
}
```
Both fields are optional. Defaults: title = `"Untitled Notebook"`, engine = `"ngspice"`.
**Response** `201`
```json
{
"id": "my-circuit-f8e2a91b",
"spicebook_version": "2026-02-13",
"metadata": {
"title": "My Circuit",
"engine": "ngspice",
"tags": [],
"created": "2026-02-13T18:30:00+00:00",
"modified": "2026-02-13T18:30:00+00:00"
},
"cells": [
{
"id": "cell-a1b2c3d4e5f6",
"type": "markdown",
"source": "# My Circuit\n\nAdd SPICE cells below to begin simulating.",
"outputs": []
}
]
}
```
#### Get notebook
```
GET /api/notebooks/{notebook_id}
```
**Response** `200` — Full `Notebook` object (same shape as create response, without `id` wrapper).
#### Update notebook
```
PUT /api/notebooks/{notebook_id}
```
**Request body** — Full `Notebook` object (replaces entire notebook).
**Response** `200` — The saved `Notebook`.
#### Delete notebook
```
DELETE /api/notebooks/{notebook_id}
```
**Response** `204` — No content. Only user-created notebooks can be deleted.
---
### Cells
Cells are ordered elements within a notebook. Each cell has a `type` (`markdown`, `spice`, `python`, `schematic`) and `source` (text content).
#### Add cell
```
POST /api/notebooks/{notebook_id}/cells
```
**Request body**
```json
{
"type": "spice",
"source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end",
"after_cell_id": "cell-a1b2c3d4e5f6"
}
```
`after_cell_id` is optional — omit to append at end.
**Response** `201`
```json
{
"id": "cell-b2c3d4e5f6a7",
"type": "spice",
"source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end",
"outputs": []
}
```
#### Update cell
```
PUT /api/notebooks/{notebook_id}/cells/{cell_id}
```
**Request body**
```json
{
"source": "V1 1 0 DC 10\nR1 1 0 2k\n.op\n.end",
"type": "spice"
}
```
Both fields optional — only provided fields are updated.
**Response** `200` — Updated `Cell`.
#### Delete cell
```
DELETE /api/notebooks/{notebook_id}/cells/{cell_id}
```
**Response** `204`
#### Reorder cells
```
PUT /api/notebooks/{notebook_id}/cells/reorder
```
**Request body**
```json
{
"cell_ids": ["cell-b2c3d4e5f6a7", "cell-a1b2c3d4e5f6"]
}
```
Must include every cell ID exactly once.
**Response** `200` — Array of `Cell` objects in new order.
---
### Simulation
#### Run standalone simulation
```
POST /api/simulate
```
**Request body**
```json
{
"netlist": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end",
"engine": "ngspice"
}
```
**Response** `200`
```json
{
"success": true,
"waveform": {
"variables": [
{"name": "v(1)", "type": "voltage"},
{"name": "v(2)", "type": "voltage"}
],
"points": 1,
"x_data": [0.0],
"y_data": {"v(1)": [5.0], "v(2)": [3.333]},
"x_type": "time",
"is_complex": false,
"y_magnitude_db": null,
"y_phase_deg": null
},
"log": "ngspice output...",
"error": null,
"elapsed_seconds": 0.42
}
```
#### Run cell in notebook
```
POST /api/notebooks/{notebook_id}/cells/{cell_id}/run
```
No request body — uses the cell's `source` as the netlist and the notebook's `engine`.
**Response** `200` — Same `SimulationResponse` shape. The cell's outputs are updated in the saved notebook.
---
### Schematics
#### Generate schematic from cell
```
POST /api/notebooks/{notebook_id}/cells/{cell_id}/schematic
```
No request body. Cell must be type `spice`.
**Response** `200`
```json
{
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>",
"success": true,
"error": null,
"component_map": {"R1": "resistor", "V1": "voltage_source"}
}
```
---
### Waveforms
#### Generate SVG plot (JSON-wrapped)
```
POST /api/waveforms/svg
```
**Request body**
```json
{
"waveform": { "...WaveformData from simulation response..." },
"title": "Output Voltage",
"width": 800,
"height": 500,
"signals": ["v(2)"]
}
```
`signals` is optional — omit to plot all signals. `width`, `height`, `title` have defaults.
**Response** `200`
```json
{
"svg": "<svg ...>...</svg>"
}
```
#### Generate SVG plot (raw)
```
POST /api/waveforms/svg/raw
```
Same request body as above.
**Response** `200` with `Content-Type: image/svg+xml` — raw SVG string.
---
### Compose (convenience)
Create a fully-populated notebook with multiple cells in a single call.
#### Compose notebook
```
POST /api/notebooks/compose
```
**Request body**
```json
{
"title": "RC Low-Pass Filter",
"engine": "ngspice",
"tags": ["filter", "rc", "analog"],
"cells": [
{
"type": "markdown",
"source": "# RC Low-Pass Filter\n\nA simple first-order low-pass filter."
},
{
"type": "spice",
"source": "V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 1u\n.ac dec 100 1 1meg\n.end"
},
{
"type": "markdown",
"source": "## Analysis\n\nThe -3dB cutoff is at f = 1/(2*pi*R*C) = 159 Hz."
}
],
"run": false
}
```
| Field | Type | Default | Description |
|----------|-----------------|----------------------|------------------------------------------------------|
| title | string | "Untitled Notebook" | Notebook title |
| engine | string | "ngspice" | Simulation engine |
| tags | list of strings | [] | Searchable tags |
| cells | list of objects | (required) | Cells to create, each with `type` and `source` |
| run | bool | false | If true, execute each SPICE cell after creation |
**Response** `201` — Same shape as `POST /api/notebooks` (notebook with `id` at top level).
When `run` is `true`, each `spice` cell is executed sequentially using the notebook's engine. Simulation results are stored in each cell's `outputs` array. Non-SPICE cells are unaffected.
---
## Data Models
### CellType (enum)
`"markdown"` | `"spice"` | `"python"` | `"schematic"`
### Cell
```json
{
"id": "cell-a1b2c3d4e5f6",
"type": "spice",
"source": "V1 1 0 DC 5\n.op\n.end",
"outputs": [
{
"output_type": "simulation_result",
"data": { "success": true, "waveform": {...}, "log": "...", "error": null, "elapsed_seconds": 0.3 },
"timestamp": "2026-02-13T18:35:00+00:00"
}
]
}
```
### Notebook
```json
{
"spicebook_version": "2026-02-13",
"metadata": {
"title": "string",
"engine": "ngspice",
"tags": ["string"],
"created": "ISO-8601 datetime",
"modified": "ISO-8601 datetime"
},
"cells": [Cell, ...]
}
```
### SimulationResponse
```json
{
"success": true,
"waveform": WaveformData | null,
"log": "string",
"error": "string | null",
"elapsed_seconds": 0.0
}
```
### WaveformData
```json
{
"variables": [{"name": "v(out)", "type": "voltage"}],
"points": 100,
"x_data": [0.0, 0.001, ...],
"y_data": {"v(out)": [0.0, 0.5, ...]},
"x_type": "time",
"is_complex": false,
"y_magnitude_db": null,
"y_phase_deg": null
}
```
For AC analysis (`is_complex: true`), `y_magnitude_db` and `y_phase_deg` contain per-signal arrays. `x_type` will be `"frequency"` and `x_data` holds frequency values in Hz.
---
## SPICE Netlist Primer
SpiceBook uses **ngspice** as its simulation engine. Netlists are plain text describing a circuit and the analysis to perform.
### Basic structure
```spice
* Title line (optional comment)
V1 node_pos node_neg DC 5 * DC voltage source
R1 node_a node_b 1k * Resistor: 1 kilo-ohm
C1 node_a node_b 100n * Capacitor: 100 nanofarads
L1 node_a node_b 10m * Inductor: 10 millihenrys
.analysis_type parameters
.end
```
Node `0` is always ground.
### Supported analysis types
| Command | Description | Example |
|---------|-------------|---------|
| `.op` | DC operating point | `.op` |
| `.dc` | DC sweep | `.dc V1 0 5 0.1` |
| `.tran` | Transient (time-domain) | `.tran 1u 10m` |
| `.ac` | AC frequency sweep | `.ac dec 100 1 1meg` |
### Engineering suffixes
| Suffix | Multiplier | Example |
|--------|-----------|---------|
| T | 10^12 | `1T` = 1 tera |
| G | 10^9 | `2.2G` = 2.2 giga |
| meg | 10^6 | `1meg` = 1 mega (note: not `M` — that's milli in SPICE) |
| k | 10^3 | `4.7k` = 4700 |
| m | 10^-3 | `10m` = 0.01 |
| u | 10^-6 | `100u` = 100 micro |
| n | 10^-9 | `47n` = 47 nano |
| p | 10^-12 | `10p` = 10 pico |
| f | 10^-15 | `1f` = 1 femto |
### Common sources
```spice
V1 node+ node- DC 5 * DC voltage
V2 node+ node- AC 1 * AC source (for .ac analysis)
V3 node+ node- PULSE(0 5 0 1n 1n 5u 10u) * Pulse source
I1 node+ node- DC 1m * DC current source
```
---
## Example Workflow
### 1. Create a notebook with the compose endpoint
```bash
curl -X POST https://spicebook.warehack.ing/api/notebooks/compose \
-H "Content-Type: application/json" \
-d '{
"title": "Voltage Divider",
"engine": "ngspice",
"tags": ["resistive", "dc", "beginner"],
"cells": [
{
"type": "markdown",
"source": "# Voltage Divider\n\nTwo resistors divide a 5V supply."
},
{
"type": "spice",
"source": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end"
},
{
"type": "markdown",
"source": "## Expected Result\n\nV(2) = 5 * 2k / (1k + 2k) = 3.333V"
}
],
"run": true
}'
```
Response includes the notebook `id` and all cells. Because `run: true`, the SPICE cell's `outputs` array will contain the simulation result with `v(2) = 3.333`.
### 2. View the notebook in the browser
Open `https://spicebook.warehack.ing/notebook/{id}` — the notebook renders with the markdown cells formatted and the SPICE cell showing its simulation output.
### 3. Add another cell
```bash
curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells \
-H "Content-Type: application/json" \
-d '{
"type": "spice",
"source": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.dc V1 0 10 0.5\n.end"
}'
```
### 4. Run the new cell
```bash
curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/run
```
### 5. Generate a schematic
```bash
curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/schematic
```
### 6. Visualize waveform data
Take the `waveform` from the simulation response and POST it to the waveform endpoint:
```bash
curl -X POST https://spicebook.warehack.ing/api/waveforms/svg/raw \
-H "Content-Type: application/json" \
-d '{
"waveform": { ...waveform object from simulation response... },
"title": "DC Sweep",
"signals": ["v(2)"]
}' \
-o plot.svg
```
---
## Health Check
```
GET /health
```
**Response** `200`
```json
{"status": "ok", "version": "2026.02.13"}
```