Tyler da29188dd0 feat(api): GET /api/{claims,remittances,activity}/stream + unsubscribe
Adds three live-tail streaming endpoints that emit an NDJSON snapshot
then forward new event-bus events as they arrive, with a 15s idle
heartbeat (overridable via CYCLONE_TAIL_HEARTBEAT_S for tests).

Each endpoint:
  1. yields a snapshot of existing rows as {"type":"item","data":<row>}
  2. terminates the snapshot with {"type":"snapshot_end","data":{"count":N}}
  3. subscribes to its event kind and forwards each new event as an
     {"type":"item","data":<event>} line
  4. emits a {"type":"heartbeat","data":{"ts":<iso>}} line every
     CYCLONE_TAIL_HEARTBEAT_S seconds when idle
  5. checks request.is_disconnected() before each yield and unsubscribes
     from the bus on cleanup so a closed stream releases its queue

The shared tail loop lives in api._tail_events, which polls
bus.subscribe_raw()'s queue directly instead of using the bus's
async-iterator wrapper — wait_for on an async generator cancels the
inner future on timeout, which poisons subsequent __anext__ calls with
StopAsyncIteration. Queue.get() is idempotent under cancellation, so
heartbeats don't break the subscription.

EventBus gains an unsubscribe(queue, kinds) method (idempotent) so
the tail loop can release its queue in a try/finally. The disconnect
test asserts the subscriber list is empty after the body iterator is
closed, validating no queue leak per open stream.

Tests in test_api_stream_live.py: 8 tests covering snapshot shape,
post-snapshot publish, heartbeat timing, multi-item snapshots, and
client disconnect cleanup. Plus 2 tests in test_pubsub.py for the
new unsubscribe method.
2026-06-20 16:57:30 -06:00

Cyclone

A self-hosted EDI claims management suite for a single billing office. Parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 and 005010X221A1), with a local-only FastAPI backend and a React UI for browsing, filtering, and inspecting the parsed data.

Local-only on purpose: binds to 127.0.0.1, no auth, no internet exposure. Built for one operator, one machine, one trading partner (Colorado Medicaid, currently).

Install

# Backend (Python 3.11+)
cd backend
python -m venv .venv
.venv/bin/pip install -e '.[dev]'

# Frontend (Node 20+)
cd ..
npm install

Dev

Two terminals:

# Terminal 1 — backend
cd backend
.venv/bin/python -m cyclone serve
# (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1)

# Terminal 2 — frontend
npm run dev
# (Vite on http://localhost:5173)

Then open http://localhost:5173. Drop an .txt 837P or 835 file on the Upload page; navigate to Claims, Remittances, Providers, or Activity to see the parsed data.

The frontend reads its backend URL from VITE_API_BASE_URL (default empty). Create a .env.local at the repo root with:

VITE_API_BASE_URL=http://127.0.0.1:8000

Without that, the UI falls back to its in-memory sample store via the existing data adapter (parses are disabled).

Test

# Backend
cd backend && .venv/bin/pytest

# Frontend type-check + build
npm run typecheck
npm run build

# Frontend unit tests
npm test

Persistence

Parsed batches, claims, remittances, matches, and activity events are stored in a SQLite file at ~/.local/share/cyclone/cyclone.db by default. The directory is auto-created on first run.

To use a different location, set CYCLONE_DB_URL:

export CYCLONE_DB_URL=sqlite:///path/to/cyclone.db
# or
export CYCLONE_DB_URL=postgresql://user:pass@host:5432/cyclone

Backup

sqlite3 ~/.local/share/cyclone/cyclone.db ".backup /path/to/backup.db"

This is safe to run while the backend is running (uses SQLite's online backup API).

Project layout

.
├── backend/
│   ├── src/cyclone/
│   │   ├── api.py             # FastAPI app, GET + parse routes
│   │   ├── store.py           # InMemoryStore, mappers
│   │   ├── __main__.py        # `python -m cyclone serve`
│   │   ├── cli.py             # click CLI
│   │   └── parsers/           # X12 tokenizer, models, validator, writers
│   └── tests/
│       ├── fixtures/          # co_medicaid_*.txt, minimal_*.txt
│       ├── test_api.py        # parse-837/835 round-trip
│       ├── test_api_gets.py   # 6 GET endpoints
│       ├── test_store.py      # store + mappers + iterators
│       ├── test_api_streaming.py
│       └── test_api_parse_persists.py
├── src/                       # React + Vite + TypeScript UI
│   ├── components/
│   │   └── ui/                # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│   ├── pages/                 # Claims, Remittances, Providers, Activity, Upload
│   ├── hooks/                 # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│   ├── lib/                   # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
│   ├── store/                 # zustand sample-data + parsed-batches store
│   └── types/                 # shared TS types
├── docs/
│   ├── reference/             # condensed 837P/835/X12/CO Medicaid notes
│   └── superpowers/plans/     # implementation plan
├── tailwind.config.js         # shimmer, scan, row-flash keyframes
└── package.json

Roadmap

Sub-projects 2, 3, and the first half of 4 are shipped. Next up:

  • Sub-project 3 (shipped) — More 837P/835 features.
    • 837P validation rules: R034 enforces REF*G1 on frequency-code 7/8 claims; R035 enforces BHT06 transaction-type-code is in the allowed set per payer config.
    • 835 CAS deep-parsing: every CAS adjustment now surfaces with its CARC reason code + label (e.g. CO-29: The time limit for filing has expired), surfaced via GET /api/remittances/{id} and rendered as an expansion row on the Remittances page.
    • 999 ACK transaction set: full inbound parser + outbound serializer. Auto-generated on 837 ingest when ?ack=true is passed to POST /api/parse-837. Persisted to the acks table, browsable on a new /acks page, and downloadable as the regenerated raw 999 text.
    • 270/271 eligibility (API-only): POST /api/eligibility/request builds a 270 from a JSON payload (subscriber, provider, payer, service type); POST /api/eligibility/parse-271 ingests the response and returns structured coverage_benefits.
  • Sub-project 4 (partially shipped) — Frontend features.
    • Per-claim detail drawer (shipped): click any row on the Claims page to open a side-panel drawer with the full claim context — state + amount header, validation panel, service lines, diagnoses, parties (billing provider, subscriber, payer), raw X12 segments, matched remittance summary, and a vertical state-history timeline. URL is synced (?claim=...) so links and back-button restore the drawer. Keyboard nav: j/k move between claims, esc closes, ? opens the cheatsheet overlay. Skeleton + error + not-found (404) states are all distinct. Powered by a new backend endpoint GET /api/claims/{claim_id} that returns the full drawer payload in a single round-trip.
    • Remaining SP4: batch diff view, real-time streaming of NDJSON GET responses, advanced filters (date range, multi-status, saved filter sets).

SP3 endpoints

  • POST /api/parse-837?ack=true — existing 837 parse, plus optional auto-generated 999 ACK persisted alongside the batch.
  • POST /api/parse-999 — parse an inbound 999 ACK and persist it.
  • GET /api/acks — list ACKs.
  • GET /api/acks/{id} — ACK detail, including the regenerated raw_999_text.
  • POST /api/eligibility/request — build a 270 from JSON.
  • POST /api/eligibility/parse-271 — ingest a 271 and return parsed coverage benefits.

The UI gains a new Acks page (sidebar entry) that lists persisted ACKs and lets you download the regenerated 999 text.

SP4 endpoints (claim drawer)

  • GET /api/claims/{claim_id} — full claim context for the drawer payload: header fields, diagnoses, service lines, parties (billing provider / subscriber / payer), validation result, raw X12 segments, matched remittance summary (or null), and the claim's state history (ordered newest-first). 404 with a structured { "error": "Not found", "detail": "Claim {id} not found" } body when the claim doesn't exist — distinct from a transient fetch failure so the UI can render a dedicated not-found state.

License

No license file yet; this is internal-use software. Add a LICENSE file when one is decided.

S
Description
No description provided
Readme 8.6 MiB
Languages
Python 62%
TypeScript 37.1%
CSS 0.4%
JavaScript 0.3%
Shell 0.1%