Files
cyclone/backend/README.md
T

155 lines
4.5 KiB
Markdown

# Cyclone Backend — X12 837P Parser
Python module + CLI for parsing X12 837P professional claim files into one
validated JSON per claim. Output is consumed by the Cyclone Vite/React
frontend via a FastAPI service (see **REST API** below).
## Install
```bash
cd backend
python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
```
## CLI
```bash
python -m cyclone.cli parse-837 path/to/837p.txt --output-dir ./claims
```
Options:
- `--payer {co_medicaid,generic_837p}` — default `co_medicaid`
- `--strict` — promote warnings to errors
- `--max-retries N` — stub in v1 (no auto-fix yet)
- `--include-raw-segments / --no-raw-segments` — default: include
- `--log-level {DEBUG,INFO,WARNING,ERROR}`
Exit codes:
- `0` — every claim parsed
- `2` — file-level failure (no envelope, no claims)
- `1` — unexpected exception
## Output
```
claims/
├── claim-991102977-20260611-CLM001.json # one per claim
├── claim-991102977-20260611-CLM002.json
└── summary.json # batch summary
```
## Programmatic use
```python
from pathlib import Path
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
from cyclone.parsers.writer import write_outputs
result = parse(Path("input.txt").read_text(), PayerConfig.co_medicaid())
write_outputs(result, Path("./claims"))
print(f"passed={result.summary.passed} failed={result.summary.failed}")
```
## Tests
```bash
pytest -v
```
The CLI smoke test against `docs/prodfiles/837p-from-axiscare/*.txt` is
auto-skipped if no production files are present.
## REST API
The Cyclone Vite/React frontend (default dev origin `http://localhost:5173`)
talks to the parser over HTTP. The API module is `cyclone.api:app`.
### Run the server
```bash
uvicorn cyclone.api:app --reload --port 8000
# or, equivalently:
python -m cyclone serve
```
### Endpoints
| Method | Path | Purpose |
| ------ | ---------------- | -------------------------------------------------- |
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
| POST | `/api/parse-837` | Upload an X12 file, get parsed claims back |
`POST /api/parse-837` accepts `multipart/form-data` with a single `file`
field. Optional query parameters:
- `payer``co_medicaid` (default) or `generic_837p`
- `include_raw_segments``true` (default) or `false`
- `strict``false` (default) or `true` (promote warnings to errors)
The response shape depends on the request's `Accept` header:
- `Accept: application/json` → a single `ParseResult`-shaped JSON object
(envelope, claims, summary).
- No `Accept` header (or anything that isn't `application/json`) →
`application/x-ndjson` streaming, one JSON object per line in this order:
`{"type": "envelope", "data": {...}}`, one `{"type": "claim", "data": {...}}`
per claim, then `{"type": "summary", "data": {...}}`.
Error responses are JSON of the form `{"error": "...", "detail": "..."}`:
- `400` — file-level parse error (`CycloneParseError`) or unknown `payer`
- `422` — at least one claim failed validation (body still includes the
full `ParseResult` so the client can render the errors)
- `500` — unexpected internal error
CORS is configured to allow `http://localhost:5173` with `GET` and `POST`
and any headers.
### Example: upload + JSON response
```bash
curl -X POST http://localhost:8000/api/parse-837 \
-F "file=@./samples/co_837p.txt" \
-H "Accept: application/json"
```
```json
{
"envelope": { "sender_id": "...", "receiver_id": "...", ... },
"claims": [ { "claim_id": "C1", ... }, { "claim_id": "C2", ... } ],
"summary": { "total_claims": 2, "passed": 2, "failed": 0, ... }
}
```
### Example: stream NDJSON from the browser
```js
const resp = await fetch("http://localhost:8000/api/parse-837", {
method: "POST",
body: formData, // FormData with a "file" entry
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (const line of buffer.split("\n")) {
if (!line) continue;
const evt = JSON.parse(line);
if (evt.type === "claim") renderClaim(evt.data);
else if (evt.type === "summary") showSummary(evt.data);
}
buffer = buffer.slice(buffer.lastIndexOf("\n") + 1);
}
```
## Spec & plan
- Design: `docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md`
- Plan: `docs/superpowers/plans/2026-06-19-cyclone-837p-parser.md`