# Project export: BenchPilot

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: An AI lab notebook driven by voice commands. Great for logging observations, going through protocols, and easily locating reagents, keeping researchers hands-free in the lab.
- Devpost: https://devpost.com/software/labmate-cbeust
- GitHub: https://github.com/bradycwong/Cal-2026-AI-Hackathon
- Team: 6 GitHub contributor(s) — Wesley Tan (83 commits), Devin AI (54 commits), edt2660 (47 commits), bradycwong (46 commits), Claude Opus 4.8 (33 commits), pyrosoldie (6 commits)

## Devpost submission (written by the team)

### Inspiration

All of us have had some microbiological research experience, and all of us have had to deal with experiments involving bacteria, fume hoods, and biosafety cabinets where, to prevent contamination, we have been limited in our ability to take notes and reference protocols.

### What it does

We made a webapp that serves as an AI based research journal and protocol tool specializing in voice command based utility, allowing the user to take notes, set timers, and navigate protocols without the need to touch their device.

### How we built it

We used a combination of Google Stitch and Claude Code to design much of the UI, while using Devin and Claude Code to create the backend. The frontend and backend were stitched together, again with a combination of Devin and Claude Code, with Codex as a reviewer for Claude's plans.

### Challenges we ran into

The main challenges we ran into were in adding functionality to Google Stitch, it produced a static page with non-interactable elements. As such many elements that were meant to be interactable ended up as placeholders that would complicate the process of merging the frontend and backend. We also struggled with parsing intent from the (sometimes unclear) Deepgram output, so we had to include bias for keywords, router guards, and step-aware answers to the LLM call.

### Accomplishments we're proud of

This was the first hackathon any of us had participated, and we are quite proud of what was produced within the 24 hours. It has more functionality than any of us expected it to have, and in addition, we felt the user interface turned out quite well.

### What we learned

We learned how to prompt and integrate LLMs into various applications. We further learned how API keys functioned, and how to separate them from the git repo to keep them private.

### What's next

Additional functionality needs to be added. Method exportation, reagent QR code scanning and logging, improve accuracy of time estimates, and improved PDF reading.

## README (from the GitHub repository)

# Lab - Voice-Driven Electronic Lab Notebook

Lab is a hands-free electronic lab notebook for running protocols in the lab:
start a voice session, load a protocol, follow the active step, log observations,
manage timers, check inventory, scale reagent prep, and keep notebook records in
sync across the UI.

## What works now

- **Live app:** FastAPI serves the current UI from `FrontendTest/` at
  `http://127.0.0.1:8000`.
- **Voice control:** browser mic audio streams to `/ws/audio`, the backend proxies
  it to Deepgram nova-3, and final transcripts run through the same command spine.
  The Deepgram key stays server-side in `.env`.
- **Always-listening mute:** after a voice session starts, the mic stays connected.
  `mute` stops transcript updates and command routing; `unmute` resumes. The mute
  state is sticky across reconnects.
- **Protocol library:** four protocols ship in `backend/data/protocols/`. The UI
  can load, import from pasted text, import from PDF, edit, and delete protocols.
- **Guided run:** the Guide page shows the active step, previous/current/next
  context, skipped/completed status, active timers, and a reagent-prep table.
- **Notebooks:** logs are SQLite-backed, scoped to the active notebook, searchable,
  editable, sortable, and exportable as Markdown, CSV, or print-to-PDF.
- **Inventory:** inventory is CSV-backed and can be searched, added, edited, and
  deleted from the UI. Voice can find items and add simple inventory entries.
- **Factory reset:** Reset Demo restores the shipped protocol and inventory seed
  data, clears run state, wipes notes/notebooks, clears timers, and unmutes voice.

## The spine

The central contract is still intentionally small:

```text
spoken transcript
  -> route(transcript)        one validated Command
  -> handle_command(Command)  deterministic state mutation
  -> emit UI events           over /ws/events
```

The frontend dispatches on four outer event types:
`transcript_update`, `command_result`, `timer_update`, and `error`. New behavior
adds `command_result.kind` values instead of adding new outer event types.

Visible UI controls usually use structured REST endpoints directly. Spoken
commands enter through `/ws/audio` and then the same `ingest()` spine used by the
backend tests and command-driven buttons.

## Run

PowerShell:

```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Copy-Item .env.example .env
.\.venv\Scripts\python.exe -m uvicorn backend.main:app --reload
```

Open `http://127.0.0.1:8000`.

Voice requires `DEEPGRAM_API_KEY` in `.env`. `ANTHROPIC_API_KEY` is optional:
with no Anthropic key, the router uses deterministic fallback paths for the demo
commands. The current `FrontendTest/` UI does not expose a free-form typed command
box, so the demo is voice plus visible page controls.

## Updated demo

1. Click **Reset Demo** to start from the factory seed state.
2. Click **Start voice session** in the lower-right dock and allow microphone
   access.
3. Say `Load DNA extraction protocol`, or open **Protocols** and click the DNA
   Extraction load button. The app lands on the Guide with step 1 active.
4. In the reagent-prep modal, set the sample count to `12` and click **Compute**.
   The table shows scaled reagent totals and inventory status.
5. On the Guide, say `Next step` or click **Confirm Action**. The current step is
   marked complete and an automatic notebook entry is written.
6. Try step controls: say `Go back`, `Repeat that`, or `Skip this step`, or use
   the matching Guide buttons. Skipped steps stay visible in the tracker.
7. When a timed protocol step appears, the timer card starts paused. Say
   `Start timer` to begin it, then say `Stop timer` or delete the timer card to
   clear it.
8. Say `Log added 200 uL lysis buffer to sample A`. Open **Notebook** to see the
   entry, provenance badge, timestamp, and any reproducibility warning.
9. Say `Scratch that`, or say `Correct that to added 300 uL lysis buffer`. You can
   also add and edit entries with the Notebook page controls.
10. On **Notebook**, create a second notebook, switch it active, and confirm
    another protocol step. New step notes land in the selected notebook.
11. Say `Where's the proteinase K?`, or search from **Inventory**. Add a new
    inventory item from the page, then edit or delete it.
12. On **Protocols**, click **Import Protocol**. Paste numbered steps or drop a
    text-readable PDF; imported protocols are registered immediately and can be
    loaded like shipped protocols.
13. Edit a protocol from its card, then click **Reset Demo**. The reset restores
    the shipped protocol/inventory seed data and removes the demo edits, imports,
    notes, notebooks, timers, and active run.

Clarification behavior is deliberate: if you say `Load a protocol` without naming
one, the UI asks which protocol instead of guessing.

## Voice examples

```text
Load DNA extraction protocol
Next step
Skip this step
Go back
Repeat that
Start timer
Stop timer
Clear done timers
Log sample A looks clear
Scratch that
Correct that to sample A looks cloudy
Where is the EDTA?
Add 5 g of EDTA on shelf 4 to inventory
How much lysis buffer in step 1?
Open notebook
Show inventory
Jump to guide
Mute
Unmute
```

The **Commands** page lists the supported voice phrases and what each one does.

## Data and reset model

- Protocols live in `backend/data/protocols/*.yaml`.
- Inventory lives in `backend/data/inventory.csv`.
- Seed copies live under `backend/seed/` and are used by Reset Demo.
- Notes and notebooks persist in SQLite at `backend/data/lab.db` by default.
- Override data with `LAB_DATA_DIR`; override notes with `LAB_DB_PATH`, or set
  `LAB_DB_PATH=:memory:` for non-persistent notes.

Reset Demo is now a full factory reset. It restores seed protocols and inventory,
clears all notes/notebooks, clears the active protocol and timers, clears recent
protocol state, and unmutes voice. It does not depend on `LAB_DEMO_MODE`.

## API surface

- `POST /api/ingest` - command spine entrypoint for tests and command-like UI
  actions.
- `GET /api/state` - hydrate active protocol, log, and timer state.
- `GET /api/protocols` and `GET /api/protocols/recent` - protocol catalog and
  dashboard recents.
- `POST /api/protocols/{id}/load` - deterministic protocol load.
- `GET/PATCH/DELETE /api/protocols/{id}` - full protocol detail, edit, and
  delete.
- `POST /api/protocols/import` - pasted prose to protocol YAML.
- `POST /api/protocols/import/file` - text-readable PDF to protocol YAML.
- `POST /api/protocols` - direct YAML upload.
- `POST /api/step/next` - button-driven confirm or skip.
- `GET/POST /api/notebooks` and `POST /api/notebooks/{id}/select` - notebook
  list, create, and switch active notebook.
- `GET/POST /api/log` and `PATCH /api/log/{id}` - active-notebook log feed and
  entry edits.
- `GET/POST /api/inventory` and `PUT/DELETE /api/inventory/{id}` - inventory
  read and item CRUD.
- `POST /api/scale` - deterministic reagent prep scaling for the active or
  selected protocol.
- `POST /api/demo/reset` - factory reset.
- `GET /api/health` - basic backend health and loaded data counts.
- `WS /ws/events` - UI event stream.
- `WS /ws/audio` - browser mic audio and voice mute controls.

## Layout

```text
backend/
  main.py              FastAPI app, REST endpoints, WS events/audio, static UI
  deepgram_stt.py      server-side Deepgram live STT proxy
  voice_control.py     always-listening mute/unmute gate
  schema.py            Command model and locked event-envelope builders
  router.py            transcript -> Command, LLM optional with deterministic paths
  handlers.py          deterministic command handlers
  state.py             protocols, inventory, timers, notebooks, reset model
  db.py                SQLite NoteStore for notes and notebooks
  protocol_import.py   pasted/PDF protocol import helpers
  pdf_extract.py       PDF text extraction and reflow
  scaling.py           reagent prep 

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 83 recognized source files, 776 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers

## Codebase structure (from repository index)

### Files (105 of 105)

```
.claude/settings.json
.env.example
.gitignore
.idea/.gitignore
.idea/.name
.idea/Cal-2026-AI-Hackathon.iml
.idea/inspectionProfiles/profiles_settings.xml
.idea/misc.xml
.idea/modules.xml
.idea/vcs.xml
backend/__init__.py
backend/aliases.py
backend/data/inventory.csv
backend/data/protocols/bacterial_transformation.yaml
backend/data/protocols/dna_extraction.yaml
backend/data/protocols/pcr_setup.yaml
backend/data/protocols/plasmid_miniprep.yaml
backend/db.py
backend/deepgram_stt.py
backend/deepgram_tts.py
backend/env.py
backend/handlers.py
backend/instrumentation.py
backend/inventory.py
backend/main.py
backend/pdf_extract.py
backend/protocol_import.py
backend/reproducibility.py
backend/router.py
backend/scaling.py
backend/schema.py
backend/seed/inventory.csv
backend/seed/protocols/bacterial_transformation.yaml
backend/seed/protocols/dna_extraction.yaml
backend/seed/protocols/pcr_setup.yaml
backend/seed/protocols/plasmid_miniprep.yaml
backend/state.py
backend/voice_control.py
CLAUDE.md
deterministic_control_routing_plan.txt
devin_execution_manifest.txt
docs/superpowers/plans/2026-06-21-reagent-scaling-prep-table.md
frontend_cleanup_plan.txt
frontend_migration_backend_plan.txt
frontend/app.js
frontend/index.html
frontend/styles.css
frontend/sw.js
FrontendTest/app.js
FrontendTest/commands.css
FrontendTest/commands.html
FrontendTest/dashboard.css
FrontendTest/dashboard.html
FrontendTest/guide.css
FrontendTest/guide.html
FrontendTest/inventory.css
FrontendTest/inventory.html
FrontendTest/note.txt
FrontendTest/notebook.css
FrontendTest/notebook.html
FrontendTest/protocols.css
FrontendTest/protocols.html
FrontendTest/shared.css
FrontendTest/tailwind-config.js
FrontendTest/voice.js
inventory_search_and_date_column_plan.txt
more_control_plan.txt
plan_demo_reset.txt
plan_full.txt
plan_skeleton.txt
protocol_import_plan.txt
README.md
reproducibility_plan.txt
requirements.txt
sample_data/inventory_additions.csv
sample_data/protocols/bca_protein_assay.yaml
sample_data/protocols/gel_electrophoresis.yaml
sample_data/protocols/rna_extraction.yaml
sample_data/README.md
tests/__init__.py
tests/test_add_inventory.py
tests/test_aliases.py
tests/test_api.py
tests/test_demo_reset.py
tests/test_env.py
tests/test_frontend_static.py
tests/test_handlers.py
tests/test_notebooks.py
tests/test_pdf_extract.py
tests/test_pdf_import_endpoint.py
tests/test_persistence.py
tests/test_protocol_edit.py
tests/test_protocol_import.py
tests/test_reproducibility.py
tests/test_router.py
tests/test_scale_endpoint.py
tests/test_scaling.py
tests/test_seed_demo_additions.py
tests/test_service_worker.py
tests/test_state_writers.py
tests/test_stt.py
tests/test_tts.py
tests/test_typed_mute.py
tests/test_upload_endpoints.py
tests/test_voice_control.py
```

### Dependencies

- requirements.txt: anthropic@==0.40.0, arize-otel@==0.13.0, fastapi@==0.115.6, httpx@==0.28.1, pydantic@==2.12.4, pypdf@==6.13.3, pytest@==8.3.4, python-dotenv@==1.2.2, python-multipart@==0.0.20, pyyaml@==6.0.3, uvicorn[standard]@==0.34.0, websockets@==14.1

### Recent commits (newest first)

- changes
- changed transcription
- Merge pull request #71 from bradycwong/fix-deduct-cascade
- Fix multi-bottle cascade deduction and keep depleted items at 0
- Merge pull request #70 from bradycwong/fix-transcript-scroll-v2
- Merge branch 'main' into fix-transcript-scroll-v2
- Merge pull request #69 from bradycwong/stt-inventory-keyword-boost
- Fix live transcript clipping newest messages
- Boost inventory item names as STT keywords for Deepgram
- Merge pull request #68 from bradycwong/devin/1782066290-tts-button-all-pages
- Merge pull request #67 from bradycwong/fix-transcript-scroll
- feat: AI Voice toggle on every page + audible confirmation on enable
- Lock live transcription box to latest message, no user scroll
- Merge pull request #64 from bradycwong/devin/1782065352-ai-tts-guide
- Merge pull request #65 from bradycwong/auto-deduct-silent
- Merge auto-deduct-silent (#65) into TTS branch; resolve endpoint + static-test conflicts
- Merge pull request #66 from bradycwong/notebook-layout-swap
- Merge origin/main into auto-deduct-silent; resolve volume-humanize + prep-modal conflicts
- Swap Notebooks and Activity Stream columns; add right margin
- Fully automatic silent inventory deduct at protocol end + upstream fixes

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A voice-driven Electronic Lab Notebook ("Lab" / BenchPilot). A researcher speaks or
types a command — load a protocol, log an observation, start a timer, find/add a
reagent, ask what's next — and the screen updates live over a WebSocket. FastAPI
backend, vanilla-JS multi-page frontend, SQLite for log persistence.

## Commands

```bash
# Setup (Python 3.12+; deps are pinned for a reproducible build, incl. py3.14 wheels)
python -m venv .venv && source .venv/Scripts/activate   # Windows; use bin/activate on POSIX
pip install -r requirements.txt
cp .env.example .env          # optional — typed demo works with NO keys

# Run (serves the UI + API on http://127.0.0.1:8000)
uvicorn backend.main:app --reload

# Test
pytest -q                                  # full suite
pytest tests/test_router.py -q             # one file
pytest tests/test_router.py::test_name -q  # one test
```

There is no configured linter/formatter — do not invent one.

## The locked spine (do not violate)

The whole product hangs off one data-flow invariant. Read `backend/schema.py` and
the `ingest()` function in `backend/main.py` before changing anything here.

```
transcript (str)
  -> route(transcript)         backend/router.py — the ONLY place an LLM may run
  -> handle_command(Command)   backend/handlers.py — deterministic, mutates SessionState
  -> broadcast(events)         WS /ws/events — 4 outer event types, frozen
```

Locked invariants:
- **One `Command` shape** (`schema.py`): flat intent enum + all-Optional payload
  fields. A clear-but-incomplete utterance ("load a protocol" with no name) is a
  *valid* Command, not a crash — the field stays null and a question goes in
  `clarify_prompt`. The router never guesses a value that wasn't said.
- **4 outer WS event types** (`transcript_update`, `command_result`, `timer_update`,
  `error`). New behavior adds a `command_result` **`kind`** (built via the
  `*_event()` helpers in `schema.py`) — never a new outer type. The frontend
  (`FrontendTest/app.js`) dispatches on these 4 types and on `payload.kind`.
- **Input channels are swappable; the spine is not.** The typed box POSTs
  `/api/ingest`; Deepgram final transcripts call the *same* `ingest()` over
  `/ws/audio`. Voice is just another way to fill the transcript.
- **`router.py` is the only LLM caller.** Everything downstream is deterministic.
  Swapping LLM providers touches only that file.

## Critical gotchas

- **The LLM router path is currently dead-by-design.** `_llm_route()` calls
  `client.messages.parse(...)`, which **does not exist** in the pinned
  `anthropic==0.40.0`. So `route()` always throws there and falls back to
  `deterministic_route()` — the regex parser in `router.py` is the live path. Do
  not "fix" this by calling `messages.parse`; if you need an LLM call on the pinned
  SDK use `messages.create` + manual JSON validation 
[truncated — 5384 more characters]
```

### docs/superpowers/plans/2026-06-21-reagent-scaling-prep-table.md

```markdown
# Reagent Scaling Prep Table Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the deterministic "Protocol Compiler" core: given a loaded protocol, sample count, and overage percent, produce a scaled reagent prep table with inventory availability checks.

**Architecture:** Keep this feature read-only and outside the locked command spine. The LLM may help protocol import create structured `parameters`, but scaling is pure deterministic Python: aggregate `parameters.reagent` plus `parameters.volume_ul`, scale quantities, compare against inventory, and expose the result through a plain REST endpoint. The served UI is `FrontendTest/`, so the dashboard adds a hook-guarded Reagent Prep panel that calls `/api/scale`.

**Tech Stack:** FastAPI, Pydantic, pure Python helpers, existing `SessionState`, existing CSV-backed inventory, vanilla `FrontendTest/app.js`, pytest.

## Global Constraints

- Do not change the locked ingest spine: `transcript -> route() -> handle_command() -> broadcast()`.
- Do not add a new `Command` intent.
- Do not add a websocket event type or websocket broadcast for scaling.
- Do not call an LLM from scaling or `/api/scale`.
- Scaling is read-only: no protocol, inventory, timer, or notebook mutation.
- Use structured step parameters only: `parameters.reagent` and numeric `parameters.volume_ul`.
- Treat all math as volume-only. Never convert mass to volume.
- Use ASCII units in code and docs: `uL`, `mL`, `L`.
- Served UI is `FrontendTest/`; legacy `frontend/` is not the target.
- There is an existing dirty worktree in this project. Do not reset, checkout, delete, or overwrite unrelated changes.

---

## File Structure

- `backend/scaling.py`: new pure module for volume conversion, reagent aggregation, scaling rows, and prep-table inventory verdicts.
- `backend/inventory.py`: add a pure `find_inventory_match()` helper next to `InventoryItem`.
- `backend/handlers.py`: refactor `_handle_find_inventory()` to use `find_inventory_match()` with no behavior change.
- `backend/main.py`: add `ScaleIn` and `POST /api/scale`.
- `FrontendTest/dashboard.html`: add the Reagent Prep panel hooks.
- `FrontendTest/app.js`: add `fetchScale()`, `renderPrepTable()`, `handlePrepCompute()`, and hook-guarded wiring.
- `FrontendTest/dashboard.css`: optional minimal styling for the prep table if existing Tailwind classes are not enough.
- `tests/test_scaling.py`: new pure-function tests.
- `tests/test_scale_endpoint.py`: new API tests.
- `tests/test_frontend_static.py`: add static hook and client-contract tests.
- `README.md`: add a short note for the demo flow and endpoint.

---

### Task 0: Baseline Guardrail

**Files:**
- Read: repository working tree
- Modify: none

**Interfaces:**
- Consumes: current repository state.
- Produces: a known baseline b
[truncated — 34182 more characters]
```

### requirements.txt

```
# Lab backend — pinned for a reproducible hackathon build.
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.12.4          # 2.12+ ships pydantic-core wheels (PyO3>=0.25) for Python 3.14
pyyaml==6.0.3              # 6.0.3 adds cp314 wheels (6.0.2 has none for Python 3.14)
anthropic==0.40.0          # router LLM (structured outputs via messages.parse)
websockets==14.1           # server-side Deepgram live STT proxy client
python-dotenv==1.2.2        # load server-side .env keys during local runs
python-multipart==0.0.20    # parse multipart/form-data for protocol file upload
pypdf==6.13.3              # extract text from uploaded PDF protocols (pure-Python, universal wheels)

# observability — Arize AX tracing via arize-otel (manual CHAIN + LLM spans;
# pulls the opentelemetry + grpcio stack as transitive deps). The Anthropic
# auto-instrumentor is intentionally NOT used: its floor is anthropic>=0.41 and
# the only older build is incompatible with this OTel stack, while we pin 0.40.0.
# Tracing no-ops at runtime unless ARIZE_SPACE_ID + ARIZE_API_KEY are set.
arize-otel==0.13.0

# dev / test
pytest==8.3.4
httpx==0.28.1              # FastAPI TestClient dependency

```

### frontend/app.js

```javascript
// app.js — WS client + typed command box.
// Renders on the 4 LOCKED event types only. Adding a new command kind must NOT
// require a new branch here beyond a `kind` case inside command_result.

const $ = (id) => document.getElementById(id);

const els = {
  conn: $("chip-conn"),
  state: $("chip-state"),
  transcript: $("transcript"),
  stepPrev: $("step-prev"),
  stepCurrent: $("step-current"),
  stepNext: $("step-next"),
  timers: $("timers"),
  log: $("log"),
  inventory: $("inventory"),
  clarify: $("clarify"),
  clarifyPanel: $("clarify-panel"),
  form: $("composer"),
  input: $("composer-input"),
  micBtn: $("mic-btn"),
  muteBtn: $("mute-btn"),
  mic: $("chip-mic"),
  transcriptHint: document.querySelector(".panel-hint"),
  srStatus: $("sr-status"),
  alertBanner: $("alert-banner"),
};

const DEMO_LINES = [
  "Load DNA extraction protocol",
  "What's next",
  "Go back",
  "Repeat that",
  "log: added 200 uL lysis buffer to sample A",
  "Scratch that",
  "Change that to added 300 uL lysis buffer",
  "Start a 10-minute timer",
  "Stop timer",
  "Where's the proteinase K?",
  "How much lysis buffer in step 1?"
];

function populateDemoLines() {
  const list = $("demo-lines");
  if (!list) return;
  list.innerHTML = "";
  DEMO_LINES.forEach((line) => {
    const option = document.createElement("option");
    option.value = line;
    list.appendChild(option);
  });
}

const timers = new Map(); // timer_id -> element

function setState(label, cls) {
  els.state.textContent = label;
  els.state.className = "chip " + (cls || "");
}

// Announce a concise message to screen readers via the polite live region.
function announce(msg) {
  if (!els.srStatus || !msg) return;
  els.srStatus.textContent = "";        // re-trigger even if text repeats
  window.requestAnimationFrame(() => { els.srStatus.textContent = msg; });
}

function fmtClock(s) {
  const mm = String(Math.floor(s / 60)).padStart(2, "0");
  const ss = String(s % 60).padStart(2, "0");
  return `${mm}:${ss}`;
}

// --- WS wiring --------------------------------------------------------------
let ws;
function connect() {
  const proto = location.protocol === "https:" ? "wss" : "ws";
  ws = new WebSocket(`${proto}://${location.host}/ws/events`);
  ws.onopen = () => {
    els.conn.textContent = "connected";
    els.conn.className = "chip chip-ok";
  };
  ws.onclose = () => {
    els.conn.textContent = "disconnected";
    els.conn.className = "chip chip-idle";
    setTimeout(connect, 1000);
  };
  ws.onmessage = (e) => dispatch(JSON.parse(e.data));
}

// --- the 4 outer event types ------------------------------------------------
function dispatch(evt) {
  switch (evt.type) {
    case "transcript_update":
      return onTranscript(evt.payload);
    case "command_result":
      return onCommandResult(evt.payload);
    case "timer_update":
      return onTimerUpdate(evt.payload);
    case "error":
      return onError(evt.payload);
    default:
      console.warn("unknown event type", evt);
  }
}

let interimEl = null;
function clearInterimTranscript() {
  if (interimEl) {
    interimEl.remove();
    interimEl = null;
  }
}

function onTranscript(p) {
  if (p.is_final) {
    clearInterimTranscript();
    const div = document.createElement("div");
    div.className = "line final";
    div.textContent = p.text;
    els.transcript.appendChild(div);
    setState("thinking", "chip-warn");
  } else {
    // interim: update a single in-place line so the panel doesn't flood
    if (!interimEl) {
      interimEl = document.createElement("div");
      interimEl.className = "line interim";
      els.transcript.appendChild(interimEl);
    }
    interimEl.textContent = p.text;
    setState("listening", "chip-warn");
  }
  els.transcript.scrollTop = els.transcript.scrollHeight;
}

// command_result dispatches on `kind` — the only place that grows per feature.
function onCommandResult(p) {
  switch (p.kind) {
    case "step_change":
      return onStepChange(p);
    case "log_entry":
      return onLogEntry(p);
    case "log_removed":
      return onLogRemoved(p);
    case "log_update":
      return onLogUpdate(p);
    case "ask_result":
      return onAskResult(p);
    case "inventory_result":
      return onInventory(p);
    case "clarify":
      return onClarify(p);
    case "voice_state":
      return onVoiceState(p);
    case "timer_removed":
      return onTimerRemoved(p);
    default:
      console.warn("unknown command_result kind", p);
  }
  setState("done", "chip-ok");
}

function fmtStep(s) {
  return s ? `Step ${s.id}: ${s.text}` : "";
}

function onStepChange(p) {
  // A null prev_step means this is the first step of a freshly loaded protocol;
  // the server cleared its timers, so drop stale timer cards to match.
  if (!p.prev_step) clearTimerCards();
  els.stepPrev.textContent = fmtStep(p.prev_step);
  const cur = p.current_step ? fmtStep(p.current_step) : "Protocol complete.";
  els.stepCurrent.textContent = cur;
  els.stepNext.textContent = fmtStep(p.next_step);
  clearClarify();
  announce(cur);
  setState("done", "chip-ok");
}

function clearTimerCards() {
  timers.forEach((el) => el.remove());
  timers.clear();
  if (!els.timers.querySelector(".muted")) {
    const m = document.createElement("div");
    m.className = "muted";
    m.textContent = "No active timers.";
    els.timers.appendChild(m);
  }
}

function removeLogEmptyState() {
  const empty = els.log.querySelector(".log-empty");
  if (empty) empty.remove();
}

function ensureLogEmptyState() {
  if (els.log.querySelector("li:not(.log-empty)")) return;
  if (els.log.querySelector(".log-empty")) return;
  const li = document.createElement("li");
  li.className = "muted log-empty";
  li.textContent = 'No entries yet - say "log: added 200 uL to sample A," or type it below.';
  els.log.appendChild(li);
}

function onLogEntry(p) {
  const li = document.createElement("li");
  removeLogEmptyState();
  li.dataset.logId = String(p.id);
  const sample = p.sample_id ? ` · sample ${p.s
[truncated — 15669 more characters]
```

### backend/main.py

```python
"""main.py — FastAPI: the single ingestion spine + WS event bus + static UI.

The spine (locked):

    transcript -> route() -> handle_command() -> broadcast() over /ws/events

Input channels are swappable; the spine is not. The typed box POSTs to
``/api/ingest`` today; live Deepgram ``is_final`` will call the SAME ``ingest()``
later. API keys are read from env ONLY and never sent to the browser.
"""

from __future__ import annotations

import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Optional

from fastapi import (
    FastAPI,
    File,
    Form,
    HTTPException,
    UploadFile,
    WebSocket,
    WebSocketDisconnect,
)
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool

from . import deepgram_tts
from .deepgram_stt import run_deepgram_session, set_custom_keywords, set_inventory_keywords
from .router import ROUTER_MODE, route
from .protocol_import import import_protocol
from .pdf_extract import PdfExtractError, extract_pdf_text, reflow_pdf_text
from .scaling import apply_reagent_deductions, build_prep_table, humanize_metric
from .reproducibility import check as check_reproducibility
from .schema import (
    Command,
    error_event,
    log_entry_event,
    log_update_event,
    notebook_list_event,
    protocol_imported_event,
    protocol_updated_event,
    reset_event,
    timer_removed_event,
    timer_update_event,
    transcript_update_event,
    voice_state_event,
)
from .handlers import advance_step, edit_log_entry, handle_command, resync_active_protocol
from .instrumentation import chain_span, setup_tracing
from .state import ProtocolParseError, SessionState
from .voice_control import VoiceControl, classify_control
from .aliases import AliasStore

# FrontendTest is the served live UI. The legacy ``frontend/`` app is kept on
# disk (its service worker is still served at /sw.js) but is no longer the root.
FRONTEND_DIR = Path(__file__).parent.parent / "FrontendTest"
LEGACY_FRONTEND_DIR = Path(__file__).parent.parent / "frontend"

# Log persistence (the one stateful organ). Override with LAB_DB_PATH; set to
# ":memory:" to disable on-disk persistence.
DB_PATH = os.getenv("LAB_DB_PATH", str(Path(__file__).parent / "data" / "lab.db"))

# File-driven protocols + inventory live here. Override with LAB_DATA_DIR (e.g. a
# scratch copy for tests) so uploads/adds don't mutate the shipped data.
DATA_DIR = Path(os.getenv("LAB_DATA_DIR", str(Path(__file__).parent / "data")))

state = SessionState(data_dir=DATA_DIR, db_path=DB_PATH)

# Process-wide mute gate shared by every input channel: the typed box, the
# spoken "mute"/"unmute", and the Mute button all toggle this one state.
voice = VoiceControl()

# User-defined custom commands (authored on the Commands page, synced here). The
# spine expands a matching trigger into its mapped built-in phrase before routing.
aliases = AliasStore()


class ConnectionManager:
    def __init__(self) -> None:
        self.active: set[WebSocket] = set()

    async def connect(self, ws: WebSocket) -> None:
        await ws.accept()
        self.active.add(ws)

    def disconnect(self, ws: WebSocket) -> None:
        self.active.discard(ws)

    async def broadcast(self, events: list[dict[str, Any]]) -> None:
        dead: list[WebSocket] = []
        for ws in self.active:
            for event in events:
                try:
                    await ws.send_json(event)
                except Exception:
                    dead.append(ws)
                    break
        for ws in dead:
            self.disconnect(ws)


manager = ConnectionManager()

# Serializes the offloaded handle_command below. The threadpool frees the event
# loop during the blocking `ask` LLM call, but two overlapping ingests could then
# mutate the shared (non-thread-safe) SessionState from two worker threads. This
# async lock keeps ingest-vs-ingest serialized WITHOUT re-blocking the loop: the
# timer loop, other routes, and the WS keep running while it is held.
ingest_lock = asyncio.Lock()


async def ingest(
    transcript: str, *, echo_transcript: bool = True, do_route: bool = True
) -> list[dict[str, Any]]:
    """The single spine. transcript in -> events broadcast + returned.

    ``do_route=False`` only echoes the transcript (used by voice after the
    heard text has already been broadcast).
    """
    events: list[dict[str, Any]] = []
    text = (transcript or "").strip()
    if not text:
        return events
    # A typed/spoken "mute"/"unmute" toggles the shared mic gate instead of being
    # routed as a lab command. (Voice handles its own control words upstream, so
    # this is the path the typed command box takes.)
    control = classify_control(text)
    if control is not None:
        vs = voice.set_muted(control == "mute")
        events.append(voice_state_event(vs.muted, vs.label))
        await manager.broadcast(events)
        return events
    # Expand a user-defined custom command into its mapped built-in phrase BEFORE
    # routing, so a custom trigger executes cleanly instead of falling through to
    # "I didn't understand that". The user's ORIGINAL words are still echoed to
    # the transcript; only the text handed to route() is the expansion.
    route_text = aliases.expand(text) or text
    if echo_transcript:
        events.append(transcript_update_event(text, is_final=True))
    if do_route:
        # CHAIN span over the routing decision + command execution. The
        # auto-instrumented Anthropic span (the `ask` path) nests under it.
        with chain_span("ingest", route_text) as span:
            if route_text != text:
                span.set_attribute("lab.alias_trigger", text)
            cmd = route(route_text)
            span.set_attribute("lab.intent", str(cmd.intent))
            # handle_command stays synchronous + deterministic (the locked
[truncated — 28680 more characters]
```

### backend/__init__.py

```python
"""Lab backend package — typed-first voice-driven ELN skeleton."""

from .env import load_project_env

load_project_env()

```

### .idea/vcs.xml

```xml
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="VcsDirectoryMappings">
    <mapping directory="" vcs="Git" />
  </component>
</project>
```

### .idea/misc.xml

```xml
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="Black">
    <option name="sdkName" value="Python 3.14 (Cal-2026-AI-Hackathon)" />
  </component>
</project>
```

### .idea/modules.xml

```xml
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectModuleManager">
    <modules>
      <module fileurl="file://$PROJECT_DIR$/.idea/Cal-2026-AI-Hackathon.iml" filepath="$PROJECT_DIR$/.idea/Cal-2026-AI-Hackathon.iml" />
    </modules>
  </component>
</project>
```

### FrontendTest/dashboard.css

```css
.glass-panel {
    background: rgba(13,17,23,0.88);
    backdrop-filter: blur(12px);
    border: 1px solid #253050;
}
.active-glow {
    box-shadow: 0 0 15px rgba(35,82,200,0.30);
}
@keyframes pulse-subtle {
    0%, 100% { opacity: 1; transform: scale(1); }
    50% { opacity: 0.8; transform: scale(0.98); }
}
.animate-pulse-subtle {
    animation: pulse-subtle 3s infinite ease-in-out;
}

.prep-table th,
.prep-table td {
  vertical-align: top;
}

```

[70 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]