Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ccc0c19
Merge device control into mobile app
leopozh Sep 20, 2026
2cb7283
Add phone-controlled Peel display demo
leopozh Sep 20, 2026
370cad4
Route phone display commands through Seeed radio
leopozh Sep 20, 2026
c586961
Restore normal Peel app with optional hardware workflow sync
leopozh Sep 20, 2026
05a068f
Accept optional diagnostic prefix in display development bridge
leopozh Sep 20, 2026
5499c99
Merge the hardware app into the Peel mobile app
viktorinkov Sep 20, 2026
f6c9842
PEEL_START=device opens the device step directly
viktorinkov Sep 20, 2026
ffbb737
Merge latest main and preserve live research with optional hardware
leopozh Sep 20, 2026
0a2000d
Combine PR 23 with tested optional hardware and display workflow
leopozh Sep 20, 2026
2e7f606
Add truepill: the spectral classification layer, tuned to the bench rig
leopozh Sep 20, 2026
7107184
CI: run the truepill tests on changes under truepill/
leopozh Sep 20, 2026
dc5df22
Merge remote-tracking branches 'origin/codex/phone-display' and 'orig…
viktorinkov Sep 20, 2026
a179344
Expose actual sensor measurements to AI and remove mock API routes
leopozh Sep 20, 2026
9386f33
Show measured legacy traces and distinguish unassessed identity
leopozh Sep 20, 2026
b26e195
Connect measured pill captures to the real classifier
viktorinkov Sep 20, 2026
285335d
Simplify Peel voice prompt and preserve research evidence
viktorinkov Sep 20, 2026
1199d67
Compute nearest synthetic reference match and restore concise voice flow
leopozh Sep 20, 2026
1397706
Merge PR 25 sensor evidence and remove runtime demo paths
viktorinkov Sep 20, 2026
3ee44b3
Merge latest PR 25 synthetic reference matching into PR 27
viktorinkov Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: backend

on:
push:
paths: ['backend/**', 'truepill/**', '.github/workflows/backend.yml']
pull_request:
paths: ['backend/**', 'truepill/**', '.github/workflows/backend.yml']

jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: '0.12.15'
- name: Install locked dependencies
working-directory: backend
run: uv sync --locked --python 3.13
- name: Test backend
working-directory: backend
run: uv run --locked pytest -q
- name: Test classifier
run: backend/.venv/bin/python -m pytest truepill -q
43 changes: 10 additions & 33 deletions .github/workflows/hardware.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,43 +20,14 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: display command framing
run: |
c++ -std=c++11 hardware/tests/display_protocol_test.cpp -o /tmp/display-protocol-test
/tmp/display-protocol-test
- name: fake board
working-directory: hardware
run: python3 sim/test_fake_board.py

app:
name: flutter app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11' # the integration tests spawn sim/fake_board.py
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.5
cache: true
- name: pub get
working-directory: hardware/peel_app
run: flutter pub get
- name: analyze
working-directory: hardware/peel_app
run: flutter analyze
- name: test
working-directory: hardware/peel_app
run: flutter test
- name: build apk
working-directory: hardware/peel_app
run: flutter build apk --debug
- uses: actions/upload-artifact@v4
with:
name: peel-app-debug-apk
path: hardware/peel_app/build/app/outputs/flutter-apk/app-debug.apk

firmware:
name: firmware
runs-on: ubuntu-latest
Expand All @@ -74,3 +45,9 @@ jobs:
- name: 21_box3_face (ESP32-S3-BOX-3)
working-directory: hardware
run: arduino-cli compile --fqbn esp32:esp32:esp32s3box firmware/21_box3_face
- name: 23_phone_display (BOX-3 radio display)
working-directory: hardware
run: arduino-cli compile --fqbn esp32:esp32:esp32s3box firmware/23_phone_display
- name: 24_phone_relay (Seeed phone bridge)
working-directory: hardware
run: arduino-cli compile --fqbn esp32:esp32:XIAO_ESP32S3 firmware/24_phone_relay
9 changes: 8 additions & 1 deletion .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ on:
push:
paths:
- 'mobile/**'
- 'hardware/sim/**'
- 'hardware/data/**'
- '.github/workflows/mobile.yml'
pull_request:
paths:
- 'mobile/**'
- 'hardware/sim/**'
- 'hardware/data/**'
- '.github/workflows/mobile.yml'

jobs:
Expand All @@ -17,10 +21,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
java-version: '21'
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.5
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/truepill.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: truepill

# Only the classification layer. Nothing here runs for a change to the rest of the repository.
on:
push:
paths:
- 'truepill/**'
- '.github/workflows/truepill.yml'
pull_request:
paths:
- 'truepill/**'
- '.github/workflows/truepill.yml'

jobs:
tests:
name: tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.13'] # the floor, and what the backend runs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: install
run: pip install -r truepill/requirements-dev.txt
- name: test
run: python -m pytest truepill -q
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Peel keeps three observations separate, then looks them up:
| --- | --- |
| **Bottle** | GPT-4o vision reads a photo of the container (name, strength, NDC, lot, manufacturer). |
| **Imprint** | GPT-4o vision reads a photo of the tablet (characters, color, shape). |
| **Pill** | A phone-attached instrument measures the physical tablet. The API still mocks this (`POST /pill` → `mock-spectrometry`). |
| **Pill** | The phone sends water and pill color sweeps to `POST /pill`. It sends the classification and aligned sensor readings to research. |

The rest of this README is the system that sits behind those three inputs.

Expand Down Expand Up @@ -150,7 +150,12 @@ The API is meant to run as a long-lived process on [Runpod](https://www.runpod.i

Locally the same app is `uv run backend` (reload on `127.0.0.1:8000`). Secrets come from a repo-root `.env`, then `backend/.env` (later wins). On boot, `app.py` calls `ensure_indices()` so the five strict Elasticsearch mappings exist before the first `POST /scans`. If the cluster is unreachable at startup, the process logs a warning and later requests return 503.

The hardware spectrometry **model** is also intended to run on Runpod. In this tree `POST /pill` returns a deterministic mock (`hardware/model = mock-spectrometry`). The physical instrument (Seeed XIAO ESP32-S3 + ESP32-S3-BOX-3 face) streams JSON over USB to `hardware/peel_app`.
`POST /pill` runs the measured TruePill classifier on separate water and dissolved pill captures.
The instrument streams JSON over USB to `mobile/`.
The scan retains timestamped sensor readings and channel statistics alongside the classification.
The library contains Advil and Pepto references.
Canned pill responses, simulated scan creation, and graph sample-data fallbacks are disabled.
See [the capture workflow and limitations](backend/pill-hardware.md).

### Elasticsearch

Expand Down Expand Up @@ -286,7 +291,7 @@ Interactive docs: [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs).
| --- | --- | --- |
| `POST` | `/photo-identification/bottle` | Vision → bottle observation |
| `POST` | `/photo-identification/imprint` | Vision → imprint observation |
| `POST` | `/pill` | Mock hardware observation |
| `POST` | `/pill` | Classify measured water and pill color sweeps |
| `POST` | `/scans` | Create scan, start pipeline (`202`) |
| `GET` | `/scans/{id}` | Poll envelope (`pending` / `partial` / `complete`) |
| `GET` | `/scans/{id}/context` | `scan_context` for the voice agent (`?as_string=true`) |
Expand Down
60 changes: 48 additions & 12 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Peel checks whether a medicine may be counterfeit, substandard or subject to a recall. A user
photographs the **bottle** label and the pill **imprint** (read by GPT-4o vision), a
phone-attached spectrometer analyses the **pill** itself (mocked in this build — see
[`mock-hardware-analysis.md`](mock-hardware-analysis.md)), and this backend's **evidence layer**
phone-attached instrument supplies color sweeps for the **pill** (see
[`pill-hardware.md`](pill-hardware.md)), and this backend's **evidence layer**
turns those three observations plus live web research into a sourced, guardrailed report. This
document covers only that evidence layer: Elasticsearch, the seed corpus, retrieval, the Elastic
Agent Builder research agent, and the scan pipeline that ties them together.
Expand Down Expand Up @@ -37,7 +37,7 @@ Three capabilities make up the layer:
```
photo(bottle) ──▶ POST /photo-identification/bottle (GPT-4o vision)
photo(imprint) ─▶ POST /photo-identification/imprint (GPT-4o vision)
spectrometer ───▶ POST /pill (mock-spectrometry)
spectrometer ───▶ POST /pill (truepill-snapshot)
│
▼
POST /scans (ScanCreate)
Expand Down Expand Up @@ -978,9 +978,9 @@ curl -s localhost:8000/scans -X POST -H 'content-type: application/json' -d '{
- **Tier-2 regulators (EMA, TGA, CDSCO, SAHPRA, PMDA, …) are not seeded** — they appear only as
domain-tier hints for live Firecrawl fetches (`research/web.py: _REGULATOR`), never as
pre-seeded `peel-regulatory` records.
- **Hardware is mocked** (`HARDWARE_MODEL = "mock-spectrometry"`); a future `peel-spectra` index
with real kNN spectral matching is out of scope here, and `hardware.spectrum` is stored
`index: false, doc_values: false` — write-only today.
- Hardware classification uses two measured reference products through `truepill-snapshot`.
The thresholds remain experimental. See [the capture contract and limits](pill-hardware.md).
Historical scans retain the `mock-spectrometry` label.
- **Agent Builder latency: ~25 s for conclusive scans, 40-60 s for ambiguous ones** (see "Latency
protocol"). A measured end-to-end run with a warm web cache: 0.3 s normalize + 0.2 s
deterministic (`partial`) + 1.3 s web + 27 s agent + 10 s coerce = 41 s to `complete`.
Expand Down Expand Up @@ -1032,9 +1032,13 @@ but nothing reads it any more; only `scripts/deepgram-chat.py` uses it locally.)
next pod start. Editing the pod env replaces the container, so keep the app on the
network volume.

The deployed API currently has no client authentication. `/pill` still returns
mock spectrometry. External service calls require valid OpenAI, Firecrawl,
and Elasticsearch credentials.
The API has no client authentication.
This branch requires real captures at `/pill`.
The old mock request is rejected. Graph demo requests return HTTP 410.
New simulated scans are rejected. Historical simulated records remain labeled.
Deploy the full repository because the backend installs the sibling `truepill/` package.
Build the matching phone app before you use the new endpoint.
External service calls require valid OpenAI, Firecrawl, and Elasticsearch credentials.

Check the running API from your computer:

Expand All @@ -1043,9 +1047,7 @@ curl --fail https://<pod-id>-8000.proxy.runpod.net/health
```

```bash
curl --fail https://<pod-id>-8000.proxy.runpod.net/pill \
-H 'Content-Type: application/json' \
-d '{"status":"unknown"}'
curl --fail https://<pod-id>-8000.proxy.runpod.net/docs
```

Open the existing pod's SSH terminal:
Expand All @@ -1067,3 +1069,37 @@ The pod is left running so the API stays available. Terminating it deletes its
container disk; the network volume remains and continues billing. For complete
cleanup, terminate the pod first, then delete `peel-fastapi-data` from the Runpod
Storage page. Deleting that volume permanently deletes the deployed files.

## Real hardware evidence

`POST /scans` accepts `hardware.sensor_readings` (up to 256 aligned samples),
`sensor_sample_count`, and the existing absorbance trace. Each sample carries
time, transmission/scattering mV, absorbance, temperature, stir percentage and
nullable colour sweep channels. Startup adds these fields to the existing scan
index without deleting data. Research and voice share bounded raw samples and
computed ranges/changes; unknown identity does not suppress the measurements.
The voice context uses `status: measured` when real readings exist without an
identified candidate. Missing/skipped hardware is never replaced by a mock.

Deploy the backend with the updated APK: older servers ignore the new sensor
fields and do not expose them to the AI. Existing stored mock scans remain
explicitly labelled as simulated. No model is asked to invent drug identity or
potency from an uncalibrated optical trace.

### Synthetic reference matching

Real scan absorbance passes through `reference_match.py` before research or voice.
It baseline-subtracts the non-sweep transmission absorbance series, resamples to 32
run-progress points, and ranks four generated curves using RMSE in absorbance units.
The generated B12, acetaminophen, vitamin C and caffeine labels are demonstration
fixtures, not measured chemical references or a trained spectrometer model. The
result says `Closest match:` with separate synthetic-library provenance. Rankings
do not use bottle names or user labels. No probabilities, potency or authenticity
claims are produced. Fewer than eight valid samples, >20% missing readings, or a
flat trace produce no match. Close rankings and out-of-library readings are marked.
Changing concentration, illumination, or run duration may change the match; this
version compares normalized run progress, not wavelengths or dissolution rates.

Voice leads with the verdict and report headline. It states the closest synthetic match when no measured classifier identity is available.
Raw telemetry and long hardware findings stay out of the voice prompt. Research retains the bounded evidence.
Synthetic reference matching does not generate or replace the mobile sensor readings.
4 changes: 4 additions & 0 deletions backend/mock-hardware-analysis.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Mock hardware analysis

This is a historical design note.
`POST /pill` now requires real captures and does not use this mock design.
See [the current hardware contract](pill-hardware.md).

The phone-attached hardware runs a spectrometry model (also deployable on Runpod) that compares the pill’s actual contents to a known pill type. We do not have that device or model in this hackathon environment, so this backend path **mocks those model results**.

Photo identification (imprint, shape, color, bottle label) is a separate step. This mock stands in for the hardware verdict only.
Expand Down
97 changes: 97 additions & 0 deletions backend/pill-hardware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Pill hardware

`POST /pill` classifies real water and pill captures through the installed `truepill` package.
It returns model `truepill-snapshot`.
The endpoint does not generate readings or accept a requested status.

## Phone capture

Connect the instrument to the Android phone with a USB data cable.
Fill the cup with clear water.
Close the lid.
Tap Water ready.
Wait for five fresh color sweeps, about 50 seconds.
Add the pill.
Tap Start stirrer.
After the pill dissolves, tap Pill dissolved.
Keep the lid closed during the sample capture.

The app records five fresh red, yellow, and green sweeps after the dissolution confirmation.
It requests a stop after 60 seconds, but only after five complete sweeps arrive.
It waits for the instrument to report that the run stopped before it calls `/pill`.
Cached sweep values do not count.
A disconnect, restart, changed water baseline, or incomplete run requires a new water capture.

`PEEL_RUN_SECONDS=0` enables manual stopping after five sample sweeps.
A shorter positive duration still waits for five sweeps.
The full raw run stays in the phone session log.
Skip hardware omits hardware from the scan and does not call `/pill`.

## Request

The JSON body contains `rig`, `pill_type`, `blank`, and `sample`.
`rig` is `peel-bench-17_stream`.
`blank` and `sample` each contain exactly five objects with this shape:

```json
{"swept": true, "sweep": {"red": 116.0, "yellow": 137.0, "green": 400.0}}
```

These numbers illustrate the format only.
Use actual millivolt readings from separate water and dissolved pill captures.
The firmware already subtracts the dark reading.
Do not subtract it again.
Do not send the single-channel `absT` series as a color spectrum.

The endpoint accepts Advil or ibuprofen, and Pepto, Pepto-Bismol, or bismuth subsalicylate as label names.
It returns `unknown` for an absent or unsupported label.
Missing, incomplete, stale, or non-finite sweeps return HTTP 422.
Physically invalid sweeps return `unknown` with verdict `INVALID_READING`.
The endpoint does not average a faulted sweep into a successful reading.

The response retains the existing `model` and `result` fields.
It adds `verdict`, `flags`, and `limitations` to explain the classification.
The app submits the returned result and model to `/scans`.
It also sends up to 256 aligned sensor samples across the full run, plus the original sample count.
Research and voice receive bounded measurements alongside the classification.
The three-color spectrum is not treated as a time trace.
Peel states the verdict first and explains the report without sensor numbers unless asked.
The research pipeline also compares the recorded time trace with synthetic reference curves.
These comparisons remain separate from the measured three-color classifier and do not establish chemical identity.

## Local setup

Use a full checkout of `codex/real-pill-integration`.
The sibling `truepill/` directory is required by the backend dependency lockfile.
From the repository root, run:

```bash
cd backend
uv sync --locked --python 3.13
```

Supply the normal backend credentials as described in [Setup](README.md#setup).
Then start the API:

```bash
uv run backend
```

The existing Runpod startup script installs the classifier through `uv sync --locked`.
This branch does not deploy or change a Runpod resource.
An older phone build that sends only `pill_type` to `/pill` receives HTTP 422.
Build the phone app from this branch when you update the backend.
The new app refuses responses from the old mock endpoint.

## Limits

The measured library contains two reference products.
The thresholds use a small set of recordings from the bench rig.
The recorded September 20 rig fault produces `unknown`, not a counterfeit finding.
Repair the detector and unstable light source before a physical acceptance test.
See [the measurements and limitations](../truepill/HARDWARE_TUNING.md).

The confidence value is a match score, not a probability.
The result does not prove authenticity or measure dose in milligrams.
The API uses the measured snapshot classifier, not the synthetic dissolution server.
Automated tests do not establish accuracy on repaired hardware.
4 changes: 4 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ dependencies = [
"python-dateutil>=2.9.0",
"rich>=15.0.0",
"typer>=0.27.2",
"truepill",
]

[tool.uv.sources]
truepill = { path = "../truepill" }

[project.scripts]
backend = "backend:main"
peel-seed = "backend.seed.cli:app"
Expand Down
Loading
Loading