Skip to content

JungHaku/InsuranceMaxx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

InsuranceMaxx

Upload an insurance document, and have an agent read it, remember what it learned, and act on it.

Today the repo contains the two pieces that exist: the intake endpoint (upload → PDF/photo → disk) and a memory client for XTrace. The agent that joins them is not built yet.

Thesis

~15% of medical claims are denied. Over half of appealed denials are eventually overturned — but 66% are never appealed at all, because appealing costs more staff labor than a small clinic has. Payers profit from that gap.

There is no general appeal strategy. Overturn rates swing wildly by procedure and by denial reason. In the California DMHC determination record, within musculoskeletal claims alone, pain-management denials are overturned 71.7% of the time while orthopedic-procedure denials are overturned 27.7% — and cutting the same claims by denial reason moves it again, 46.3% for medical necessity against 27.5% for experimental/investigational. Winning is procedure-specific procedural knowledge: what a reviewer needs to see before reversing a pain-management denial on medical necessity, versus what it takes to overcome an experimental/investigational exclusion. Today that knowledge lives in the head of one experienced billing manager, and it walks out the door when she quits.

InsuranceMaxx turns that tribal knowledge into machine memory. Every appeal outcome — win or loss — rewrites the agent's playbook for that payer. The agent doesn't just know more over time; it gets measurably better at the job.

Share rules, not patient episodes

Memory is scoped so that adjudication policy propagates across clinics while patient-linked content never leaves the clinic that owns it. Clinic A's win on a pain-management denial teaches Clinic B's agent tomorrow, because "a nerve block denied as not medically necessary reverses when the record documents failed conservative therapy and a specific functional deficit" is a rule about how claims of that shape get adjudicated, not a fact about a person. The denial letter, the chart, the patient — those stay put.

This is the moat and the compliance story in one sentence: the tenth clinic to join is more valuable than the first, and no patient data is what makes it valuable.

Why this is a memory problem, not a document-generation problem

A model that writes a beautiful appeal letter from scratch every time is a letter mill. The value is in the compounding: recall what worked before acting, capture what the human corrected, ingest the outcome, revise beliefs when a strategy stops winning. The tenth appeal against a given payer should not look like the first.

Two numbers, instrumented from the first commit:

  • Overturn rate — rolling, across the run sequence
  • Human edit distance — how much a reviewer had to change the draft, trending toward zero

If those two aren't moving, the project doesn't work, however good the letters look.

Demo script

Build status: only Beat 1's intake step exists today (drag a file onto the platform → POST /api/upload → file in src/intake/inbox/). Everything below is the target.

Beat 1 — one case, end to end. Upload a denial letter. The agent recalls (recalled memories shown on screen, labeled by type), triages with an expected-value number against the ~$44 cost to fight, and drafts a cited appeal. The reviewer edits two lines; the diff is captured as a lesson.

Beat 2 — the money shot. Run 1 versus run 30 on the same denial type. Run 1 writes a generic appeal and loses. Run 30 cites the exact criteria this payer requires and wins. Between them: the playbook that built itself, and the two metric curves.

Beat 3 — the moat. Clinic A's win posts to shared memory as a policy rule. Clinic B's agent, seeing that denial type for the first time, already knows the play. The patient episode stays in Clinic A.

Beat 4 — the close. A new claim, pre-submission: "This will be denied. Add this documentation." Denials prevented, not just appeals won.

Honesty

Stated in the demo, not buried here:

  • Data provenance. Cases and outcomes come from California DMHC Independent Medical Review determinations — real denials, real payer reasoning, real overturn/uphold outcomes, published by a state regulator. Denial letters are rendered from those records using real published payer policy language. Synthetic patients, real rules, real outcomes. Train/holdout split by date.
  • No payer is named. DMHC publishes these determinations payer-anonymized, so the agent learns procedure-category and denial-reason playbooks rather than payer-specific ones. We name no payer we cannot substantiate.
  • No PHI. Nothing in this system has touched protected health information.
  • The adjudicator is simulated and knowledge-isolated from the drafting agent — they share no prompts, memory, or context. Without that separation the improvement curve would be circular and meaningless.
  • What we'd measure with a design partner: true overturn rate against live payers, appeal turnaround time, and recovered revenue per clinic-month.

Layout

src/intake/     intake.py + inbox/   the FastAPI receiver (runtime)
src/memory/     xtrace_client.py     XTrace Memory client, requests-only (runtime)
scripts/        spikes and one-offs  not on the runtime path
data/           IMR CSV lands here   gitignored except .gitkeep
docs/           SPIKE.md             XTrace findings + raw spike output

Python is the only runtime. scripts/try_xtrace.mjs is kept purely as reference for how the Node SDK behaves; nothing in the agent path shells out to Node, and node_modules/ was deliberately not carried over.

Configuration

One .env at the repo root, gitignored. .env.example lists the keys with placeholder values.

Key Used by Notes
XTRACEAI_API_KEY src/memory/ Not XTRACE_API_KEY. Values start mmk_
ANTHROPIC_API_KEY the agent (unbuilt) placeholder in .env.example only

The XTRACEAI_API_KEY name is load-bearing — load_client() checks it first and only falls back to XTRACE_API_KEY. Don't rename it.


Intake service

A FastAPI service that receives uploaded documents and drops them in src/intake/inbox/ for downstream processing.

Documents are uploaded through the platform itself — drag a file onto the page or use the picker. PDFs are the normal case; JPEG/PNG/HEIC are also accepted so a photo of a letter degrades gracefully instead of failing hard. PDFs are stored exactly as received — never converted or re-encoded. The one exception is HEIC, which is normalized to JPEG at this boundary because Claude cannot read HEIC (see below).

Endpoints

Method Path Body Returns
POST /api/upload the file's raw bytes {"case_id", "filename", "original_name", "bytes", "kind", "media_type", "converted_from"}
GET /health {"ok": true}

Raw body rather than multipart/form-data: multipart would pull in python-multipart for no gain, since one file per request needs no part-boundary parsing. The browser sends the File object straight as the body.

case_id is an 8-character uuid; the file lands at inbox/<case_id><ext>, where the extension comes from sniffing the payload's magic bytes rather than trusting anything the client claims. An optional X-Filename header is recorded in the log line only — a file named .pdf over a zip's bytes is still rejected as a zip. Uploads are capped at 25 MB.

Supported types and the downstream contract

kind tells downstream which Claude content block to use:

Magic bytes Stored as kind media_type Claude block
%PDF .pdf document application/pdf document block
\xff\xd8\xff .jpg image image/jpeg image block
\x89PNG\r\n\x1a\n .png image image/png image block
ftyp + HEIF brand .jpg image image/jpeg image block

HEIC is converted to JPEG at intake via macOS's built-in sips (formatOptions best — these are documents, so legibility beats file size). Claude's image blocks accept image/jpeg, image/png, image/gif, and image/webp but not image/heic, so an unconverted HEIC would fail at the model call instead of at the boundary. Converting here keeps the failure early and keeps the agent code free of format branching — downstream only ever sees types Claude can read.

Conversions are visible two ways: the response carries "converted_from": "image/heic" (null otherwise), and the log line ends with — CONVERTED from image/heic. If sips fails, intake returns 400 rather than storing an unreadable file.

PDFs are never touched. Anything unsupported returns 400 with a message naming what actually arrived.

Errors

Every failure returns HTTP 400 with a plain-language {"error": "..."} naming what actually arrived — the UI renders that string verbatim, so the response body is the error surface. Covered: empty body, oversize payload, and unsupported file types (identified as zip, WebP, GIF, video, or a byte preview).

Run it

From the repo root:

./scripts/start_platform.sh

That starts the server and opens the platform. To run it by hand:

uv run uvicorn intake:app --app-dir src/intake --host 127.0.0.1 --port 8000

--app-dir puts src/intake/ on the import path, so inbox/ resolves next to intake.py regardless of where you invoke it from. It binds to loopback: the platform holds denial letters and has no auth, so it should not be reachable from other machines.


Memory

src/memory/xtrace_client.py is a dependency-light XTrace Memory client built on requests — ingest, search, get/delete/list, groups, and a client-side recall() fan-out. Findings from the API spike, including the endpoints that turned out not to exist, are in docs/SPIKE.md, with raw output in docs/spike_results.json.

Re-run the spike with:

uv run python scripts/xtrace_spike.py

Nothing in the intake path imports this yet — wiring the two together is the agent work, which is not started.


Case corpus

scripts/build_corpus.py renders the DMHC determinations into appeal cases. Deterministic templates, no model call anywhere in the build.

uv run python scripts/profile_imr.py    # column/value profile of the raw CSV
uv run python scripts/build_corpus.py   # → data/cases/{train,holdout}/*.json

200 cases, musculoskeletal, across three treatment categories, split by ReportYear so memory cannot peek at outcomes it will be evaluated on:

Split n Years Overturned
train 141 2019–2023 63.1%
holdout 59 2024–2025 67.8%

The playbook key is (TreatmentCategory, Type). Its overturn spread across the corpus runs from 18.8% (Orthopedic Proc | Experimental/Investigational) to 90.2% (Pain Management | Medical Necessity) — that spread is the signal, so sampling is proportional and outcome balance is deliberately not normalized.

Each case JSON carries three parts:

  • letter — the rendered denial notice, the only thing an agent should see
  • structured — requested service, diagnosis, treatment category, denial reason, playbook key
  • ground_truth — the real determination plus the IMR reviewer's rationale, preserved verbatim and unsummarized, because that text is what procedural memory learns from

provenance on every case records that no payer is named, that no CPT/CARC/RARC or claim identifiers were synthesized, and that only ReportYear is real (the month on the letter is deterministic filler).

Letters are rendered solely from the request, diagnosis, and denial reason — never from the reviewer's determination. The build asserts zero letters contain outcome language; it currently reports 0 of 200.

data/ is gitignored, so the corpus regenerates from the CSV rather than being committed.


The agent loop

Three separated functions, one cycle per denial:

recall(treatment_category, denial_type)   → memories from XTrace  (mode=retrieve)
act(denial_event, memories)               → triage decision + appeal draft
save(outcome, missed_criteria)            → ingest the lesson back to XTrace

A cycle runs: recall → act → adjudicate → simulate reviewer edit → save. Cycles are concentrated in a single playbook cell so memory compounds, which is the run-1-versus-run-30 comparison the demo turns on.

Knowledge isolation

The drafting agent never sees the IMR reviewer's Findings text or the criteria derived from it. It learns which criteria matter only from adjudicator rejection feedback. Without that separation the improvement curve would be circular: an agent handed the answer key would look like it was learning while measuring nothing.

This is enforced in code, not by convention — see src/agent/contract.py:

  1. DenialEvent is the only case representation the drafting side accepts. It is built by load_denial_event(), which reads letter and structured and never opens ground_truth. No field on it can carry the rationale.
  2. AnswerKey is loaded by a separate function used only by the criteria extractor and the adjudicator.
  3. Every drafting prompt passes through guard_prompt(), which raises IsolationBreach on contact with answer-key material. It compares six-word content shingles rather than exact substrings, so a paraphrase or a reformatted copy is caught too.

Breaking isolation therefore requires deleting code, not forgetting a rule. tests/test_isolation.py asserts it both ways: the guard never fires on a real drafting prompt across every train case, and it does fire on a planted rationale slice. A guard that cannot fail proves nothing, so the positive control is part of the suite.

The adjudicator never looks up the real outcome. Overturn rates run ~90% in the largest cell, so deciding by outcome-lookup would make the agent's work irrelevant and flatten the curve by construction. The decision is a function of the appeal text against the criteria: the model marks each criterion addressed or missed, and coverage against a threshold produces overturn/uphold in code, never as a model judgment. The real IMR outcome is carried alongside for reporting only.

Memory, as measured

Two facts verified against the live XTrace API rather than assumed:

  • Conversation-turn framing lets XTrace extract memories itself. The type that comes back is fact, not procedural.
  • retrieve returns in ~650 ms with discriminative scores (0.699, 0.648); compose takes ~1460 ms and collapses them to 1.0 / 0.999. The loop uses retrieve wherever ranking matters.

Memory is scoped per playbook cell and per run, so each run starts from an empty playbook and runs cannot contaminate each other.

Running it

uv run python tests/test_isolation.py                    # prove the boundary (offline, no API)
uv run python src/agent/criteria.py                      # cache criteria (adjudicator side)
uv run python src/agent/runner.py --cycles 30 --run-id treat30
uv run python src/agent/runner.py --cycles 30 --run-id ctrl30 --no-memory
uv run python scripts/build_dashboard.py --run treat30   # → runs/treat30.html

Offline only. Recall is ~650 ms and model calls stack on top, so cycles are never run live during a demo — the dashboard reads runs/*.json.

Memory is scoped by run_id and persists in XTrace across restarts. Reusing a run_id inherits that run's playbook, so a genuine cold start needs a new one.

Measuring whether memory does anything

A within-run curve cannot prove memory works when coverage starts near the ceiling — a flat line then means "already good", not "learning nothing". So the runner has a control arm:

Arm Command Recall Save
treatment --run-id treat30 yes yes
control --run-id ctrl30 --no-memory no no

Same cases, same order, same models. The gap between the arms is memory's contribution, and it is legible even when the within-run curve is flat.

The dashboard

scripts/build_dashboard.py renders a run into one self-contained HTML file — no server, no build step, no network. It shows the three curves (criteria coverage, rolling overturn rate, human edit distance), each with a first-third-versus-last-third delta and an explicit improving / flat / degrading verdict, then every cycle as an expandable row: the memories recalled, criteria addressed versus missed, the adjudicator's reasoning, and the appeal draft.

Model split

Role Model Why
Drafting claude-opus-5 The one call where quality decides whether the curve moves
Criteria extraction claude-haiku-4-5 Structured extraction over ~200 cases, no judgment
Adjudication claude-haiku-4-5 Structured yes/no per criterion, runs every cycle
Reviewer-edit sim claude-haiku-4-5 Only needs a plausible edit for the distance metric

Assumptions

  • macOS-only intake. HEIC conversion shells out to sips, which is a system binary. On Linux this needs a different converter.
  • Single user, no auth. Nothing on the platform is authenticated, so it binds to loopback and is not reachable from other machines. Fine for one operator on one laptop; not fine beyond that.
  • inbox/ is a queue, not storage. Files accumulate and are never rotated or cleaned up.
  • Uploads are capped at 25 MB and read fully into memory, which is fine at one file per request and would not be under real concurrency.
  • data/ is empty by design. The IMR CSV is expected to land there; it is gitignored so the dataset never enters git history.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages