Mulligan AI
AI-powered healthcare workflow platform for generating insurance appeals.
Insurance denials are common, slow to appeal, and land hardest on the patients and providers who have the least time and expertise to fight them. Writing a good appeal means pulling together a denial letter, the underlying medical claim, and the specific policy language it's supposed to satisfy, into one coherent, persuasive document, and doing that well is a genuinely different skill than practicing medicine. Mulligan AI is my attempt at closing that gap. Upload a denial document, and it extracts the relevant details and drafts a supporting appeal grounded in what was actually submitted, instead of a generic template.
Why this is harder than "summarize a PDF"
The obvious naive approach, paste a denial letter into a general-purpose model and ask for an appeal, fails in a specific and predictable way: the model will happily write something fluent and confident that isn't actually grounded in the claim, the policy, or the denial reason in front of it. In a healthcare context that's not a minor quality issue, it's the difference between an appeal that cites the right policy clause and one that sounds right but doesn't hold up. So the real problem wasn't "can an LLM write an appeal letter," it was building a pipeline that reliably extracts structured facts from messy, inconsistently formatted documents, keeps generation grounded in the specific source material for that appeal, and does all of it inside an architecture appropriate for handling real healthcare data, as a real multi-user product with accounts and history rather than a one-shot script.
The stack
The frontend is Next.js and React, the upload, review, and appeal-editing surface, built for fast iteration and a straightforward deploy. Supabase and Postgres handle accounts, document metadata, and generated appeal records, with row-level security scoping every query to the user who owns it rather than trusting the application layer alone to enforce that boundary. AWS handles file storage and background processing alongside the Supabase-hosted data layer. The part that actually does the interesting work is the retrieval-augmented generation pipeline sitting in the middle: OpenAI embeddings feeding a Pinecone vector index, scoped per appeal, so a query for one user's appeal can never surface another user's documents.
How a document actually becomes grounding context
A document comes in as PDF, Word, or plain text and gets extracted to raw text first. From there it's split into roughly 1,000 character chunks and embedded with OpenAI's text-embedding-ada-002, and each chunk gets upserted into Pinecone tagged with metadata: appeal ID, document type, file name, chunk index. That metadata tagging is what lets a query stay scoped to one appeal's own documents instead of searching across every user's data at once, which matters both for correctness and for not leaking one patient's information into another's generated appeal.
One detail I had to get right and keep consistent: the embedding model outputs 1536 dimensions, but the Pinecone index is provisioned at 1024. Every vector gets truncated down before storage, and every query embedding gets truncated the exact same way before search. Get that inconsistent, truncate on one side and not the other, and similarity search quietly degrades instead of failing loudly, which is a much worse failure mode to debug than an outright error.
Very short documents, denial letters under about 1,000 characters, skip the vector store entirely and go straight into the prompt as-is. Chunking and embedding a single short paragraph adds a network round trip for retrieval that would have nothing meaningful to retrieve beyond the whole document anyway, so the small-document path is a plain pass-through instead of forcing every document through the same machinery regardless of size.
At generation time, the denial reason itself becomes the search query. It gets embedded and matched against Pinecone with a metadata filter on that specific appeal ID, the top results come back as raw text chunks, and those get stitched into the prompt alongside the structured fields already extracted from the denial letter. Only after that retrieval step runs does the model actually draft anything. Retrieval happens before generation on every single request, which is what keeps the draft grounded in whatever was actually uploaded for that appeal instead of the model's general background knowledge of how insurance policy language tends to read.
What I'd still want to build
The current pipeline gets the core loop right: extract, retrieve, ground, generate. Where I'd want to keep pushing is tighter citation, having the model point back to the exact chunk and source document a given claim in the appeal came from, so a reviewer can verify it in seconds instead of re-reading the whole source document. That's the natural next step for a tool whose entire value proposition is trustworthiness, not just fluency.