# 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 ```bash # 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: ```bash # 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 ```bash # Backend cd backend && .venv/bin/pytest # Frontend type-check + build npm run typecheck npm run build # Frontend unit tests npm test ``` ## Live updates The Claims, Remittances, and Activity pages stay current without manual refresh. The backend publishes an internal event on every store write, the page opens a streaming HTTP connection to the matching `/api//stream` endpoint, and new rows are appended to the table the moment they hit the database. ### Wire format Each stream endpoint emits newline-delimited JSON. The first batch is the **snapshot** of currently-known rows; after that comes **`snapshot_end`** with the count, then the **live** events. ```json {"type":"item","data":{"id":"CLM-1", "...":"..."}} {"type":"item","data":{"id":"CLM-2", "...":"..."}} {"type":"snapshot_end","data":{"count":2}} {"type":"item","data":{"id":"CLM-3", "...":"..."}} ← live {"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}} ← idle keep-alive ``` Lines are `{"type": ..., "data": ...}`; known types are `item`, `snapshot_end`, `heartbeat`, and (rare) `item_dropped` / `error`. Heartbeats keep the connection alive when nothing is happening — clients flip to `stalled` after 30s of total silence (heartbeat or otherwise) and surface a **↻ Reconnect** button. ### Endpoints | Method | Path | Subscribes to | Default sort | | ------ | -------------------------- | ------------------- | ------------------- | | GET | `/api/claims/stream` | `claim_written` | `-submission_date` | | GET | `/api/remittances/stream` | `remittance_written`| `-received_date` | | GET | `/api/activity/stream` | `activity_recorded` | `-timestamp` (limit 50) | All three accept the same query params as their non-streaming counterparts (`status`, `payer`, `date_from`, …) so a frontend can swap a one-shot fetch for a tail with no URL surgery. Responses are `Content-Type: application/x-ndjson`. ### Status pill The pill in each toolbar reflects the current connection state: | Status | Badge variant | What it means | | ------------- | ------------- | ------------------------------------------------------------------ | | `live` | success | snapshot received, listening for new events | | `connecting` | warning | opening the stream (initial mount) | | `reconnecting`| warning | previous attempt failed, backing off before retry | | `stalled` | destructive | no event (including heartbeat) for 30s — click **↻ Reconnect** | | `error` | destructive | stream errored — click **↻ Reconnect** | | `closed` | destructive | page unmounted | The reconnect button is only shown on `stalled` and `error`. The backoff schedule on error is `1s → 2s → 4s → 8s → 16s → 30s` capped. ### Knobs | Env var | Default | Effect | | ---------------------------- | ------- | ------------------------------------------------------- | | `CYCLONE_TAIL_HEARTBEAT_S` | `15` | Idle heartbeat interval (seconds). Lower it for tests. | ## Inbox `/inbox` is the working surface. Four lanes, dark by default (Ticker Tape aesthetic): - **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk. - **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss. - **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold. - **Done today** — terminal state transitions in the last 24 hours. The page subscribes to the SP5 claim and remittance tail streams (`/api/claims/stream` and `/api/remittances/stream`); a new `item` event refetches the lane payload (debounced ~250ms). When a 999 parses and rejects claims, the inbox reflects the new **Rejected** rows within a fraction of a second. ### Inbox endpoints | Method | Path | Notes | | ------ | --------------------------------------------- | ---------------------------------------------------------------- | | GET | `/api/inbox/lanes` | All four lanes in one call. | | POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. `409` if the claim state moved out from under us. | | POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. | | POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. `200` with `conflicts` for non-rejected. | | GET | `/api/inbox/export.csv?lane=` | Streams CSV of the lane's rows. | ## Per-Line Adjustment Audit Every 835 CAS segment is tied back to the specific 837 service line it adjudicates, instead of being aggregated to the claim level. The match runs **eagerly** at 835 ingest time so the audit trail is stable across re-reads (a re-migration is required if the algorithm changes). ### Where to find it - **ClaimDrawer → "Line Reconciliation" tab** — side-by-side view of the 837 SV1 lines and 835 SVC composites with per-line CAS reasons. - **ClaimDrawer → ServiceLinesTable** — every billed line now shows its paid amount and adjustment sum on the right. - **RemitDrawer → CasAdjustmentsPanel** — each CAS row carries a `proc · #N` reference; claim-level CAS clusters separately. - **ClaimDrawer → MatchedRemitCard** — a `lines: N/M matched` badge lights up amber when at least one line is unmatched. ### Match criteria (strict) A 835 SVC composite matches a 837 SV1 line iff all four criteria align: | Field | Rule | | ------------- | ----------------------------------------------- | | Procedure | Exact match (case-insensitive, uppercased). | | Modifiers | Set-equal (order-independent). | | Service date | Exact match (both null counts as a match). | | Units | Exact match (both null counts as a match). | Unmatched lines surface as a **soft warning** — the claim still posts to PAID/PARTIAL/RECEIVED, but the Inbox shows the unmatched count and the drawer surfaces a per-line "no 837 line matched" note. ### Line-level endpoints | Method | Path | Notes | | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------- | | GET | `/api/claims/{claim_id}/line-reconciliation` | Dedicated view for the drawer tab. | | GET | `/api/claims/{claim_id}` | Now includes a slim `lineReconciliation[]`. | | GET | `/api/remittances/{remittance_id}` | Now includes `serviceLinePayments[]` + `claimLevelAdjustments[]`. | | GET | `/api/inbox/lanes` | `matched_remittance` payload gains `matched_lines` + `total_lines`. | ### Scoring 4-field weighted (sum = 100): | Field | Weight | Rule | | ----------------------- | ------ | ------------------------------------------------------------- | | Patient control number | 40 | Exact match, normalized (case + leading zeros). | | Service date | 25 | Linear decay over ±3 days. | | Charge amount | 20 | Linear decay over ±10%. | | Provider NPI | 15 | Exact match. Missing → 0. | Tiers: **strong** (≥75, full opacity, Match enabled), **weak** (50–74, dimmed), **hidden** (<50, not surfaced). ## 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`: ```bash export CYCLONE_DB_URL=sqlite:///path/to/cyclone.db # or export CYCLONE_DB_URL=postgresql://user:pass@host:5432/cyclone ``` ### Backup ```bash 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, /api/{resource}/stream │ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out) │ │ ├── store.py # InMemoryStore, mappers, publish-on-write │ │ ├── __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_stream_live.py # 3 live-tail endpoints + disconnect cleanup │ ├── test_pubsub.py # EventBus + subscribe/unsubscribe │ └── test_api_parse_persists.py ├── src/ # React + Vite + TypeScript UI │ ├── components/ │ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, … │ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button │ ├── pages/ # Claims, Remittances, Providers, Activity, Upload │ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse │ │ # + useTailStream, useMergedTail (live tail) │ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts │ │ # + tail-stream.ts (NDJSON parser) │ ├── store/ # zustand sample-data + parsed-batches store │ │ # + tail-store.ts (FIFO-capped live tail slices) │ └── 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, 4, and 5 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, advanced filters (date range, multi-status, saved filter sets). - **Sub-project 5 (shipped) — Live updates.** - The Claims, Remittances, and Activity pages stream new rows in real time over Server-Sent Events encoded as newline-delimited JSON. The backend publishes a `claim_written` / `remittance_written` / `activity_recorded` event on every store write; the frontend opens an HTTP stream and dispatches each event into a per-resource Zustand store that the page reads on top of its base query results. - A small status pill in each toolbar surfaces the connection state — `live` / `connecting` / `reconnecting` / `stalled` / `error` / `closed` — and offers a manual **↻ Reconnect** button on `stalled` or `error`. Stale connections (no event for 30s, heartbeat included) flip to `stalled` automatically; the back-off ladder on errors is 1s → 2s → 4s → 8s → 16s → 30s capped. See the "Live updates" section below for details. ### 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. ### SP5 endpoints (live updates) - `GET /api/claims/stream` — NDJSON stream of claim events. Emits a snapshot (filtered by the same query params as `GET /api/claims`), then `snapshot_end`, then live `claim_written` events as they arrive, with a 15s idle heartbeat. - `GET /api/remittances/stream` — same shape for remittances (subscribes to `remittance_written`, default sort `-received_date`). - `GET /api/activity/stream` — same shape for activity events (subscribes to `activity_recorded`, default `limit=50`). ## License No license file yet; this is internal-use software. Add a `LICENSE` file when one is decided.