diff --git a/use-cases/aayushmishraaa/payer-appeals/.env.example b/use-cases/aayushmishraaa/payer-appeals/.env.example new file mode 100644 index 0000000..7281de5 --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and fill in. Never commit .env. +SUPERDOCS_API_KEY=your-key-here +SUPERDOCS_BASE_URL=https://api.superdocs.app diff --git a/use-cases/aayushmishraaa/payer-appeals/.gitignore b/use-cases/aayushmishraaa/payer-appeals/.gitignore new file mode 100644 index 0000000..69cac2e --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/.gitignore @@ -0,0 +1,4 @@ +.env +.venv/ +__pycache__/ +*.pyc diff --git a/use-cases/aayushmishraaa/payer-appeals/README.md b/use-cases/aayushmishraaa/payer-appeals/README.md new file mode 100644 index 0000000..0897918 --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/README.md @@ -0,0 +1,131 @@ +# Payer appeals — drafted, reviewed, exported + +When a health insurer denies a claim, someone on the provider side writes an appeal letter that +pairs the payer's stated reason for denial with the specific clinical facts that rebut it. +Getting that pairing right by hand is slow. Getting it *wrong* by machine is worse. + +This app takes **one denial reason and one clinical fact**, drafts an appeal letter on the +SuperDocs API, holds every proposed change for a human decision, and exports only what a person +approved. + +Built by **Aayush Mishra** for the SuperDocs engineer task. + +--- + +## Run it + +```bash +cp .env.example .env # add your SUPERDOCS_API_KEY +./run.sh # http://localhost:8080 +``` + +That is the whole setup. Everything in the demo is fictional and the UI says so in a banner +that does not go away. + +--- + +## The thing this app is actually about + +A model given a denial reason and a clinical fact will write a fluent, professional appeal +letter **whether or not the fact answers the denial**. When they do not match, the way it +bridges the gap is by inventing clinical detail. + +A fabricated clinical assertion in a payer appeal is not a bad user experience. It is a false +statement submitted to an insurer over a clinician's name. + +So the app does three things a plain wrapper would not: + +### 1. It checks relevance *before* drafting + +`app/grounding.py` classifies the denial into a category — prior authorisation, medical +necessity, timely filing, coding, eligibility, duplicate — and asks whether the supplied fact +speaks to that category. The verdict is shown to the reviewer before they read a word of the +letter. + +### 2. When the fact does not fit, it says so in the letter + +It still drafts. Refusing would just push the user to write it by hand with no warning at all. +But it drafts honestly. Given a prior-authorisation denial and a fact about seasonal allergies, +the letter it produced was: + +> "Please note that the documentation directly addressing the prior authorization requirement +> is **not enclosed** with this letter. We will forward it..." + +It stated the fact accurately, admitted the gap, and asked for reconsideration once real +documentation is supplied. It did not manufacture a rebuttal. + +### 3. It flags specifics nobody supplied + +`unsupported_specifics()` finds numbers, dates, dosages and measurements that appear in the +drafted letter but in none of the inputs. It cannot judge meaning — but a payer will verify +every concrete detail, and a clinician will be asked to stand behind it, so the reviewer sees +those before sending. + +--- + +## The human gate is real + +- Every proposed change is shown with **before and after**, and decided individually. +- Rejecting one leaves the others untouched. +- **Export is refused with a 409 while any decision is outstanding.** A review you can skip is + decoration. +- A large edit arrives in **several rounds**; the app tells the reviewer when more changes are + waiting rather than letting them believe they have seen the whole letter. + +In a live run: 4 changes proposed, 3 approved and 1 rejected. In the exported `.docx` the +rejected section still reads `[What is enclosed, and what is not.]` — the placeholder was never +filled, because that change was rejected. The rejection is visible in the artefact. + +--- + +## What it uses + +| SuperDocs surface | Used for | +| --- | --- | +| `POST /v1/chat/async` with `approval_mode: ask_every_time` | Draft into the letter template without self-applying | +| `GET /v1/jobs/{id}` | Poll until a human decision is required | +| `POST /v1/chat/{session}/approve` | Per-change approve/reject with feedback | +| `POST /v1/documents/export` | `.docx` / `.pdf` | +| `GET /v1/sessions/{id}/jobs` | Recover a session wedged by an abandoned review | + +The letter starts from a **template document** rather than being generated from nothing. That +is deliberate: appeal letters have a house format a compliance team already approved, and +starting from a document means the model performs targeted edits on named sections — which is +what SuperDocs is good at — rather than generating prose, which everything is good at. + +--- + +## Things I learned against the live API + +Encoded here so the next person does not spend the same afternoon: + +- The upload field is **`file_base64`**, not `content_base64`. The 422 body is the only place + the correct name appears. +- `pending_changes` can be present with a **null** value, so `.get("pending_changes", [])` + returns `None`, not `[]`. That crashed this app once. +- A large edit arrives in **several approval rounds**. Approve once and wait for completion and + you poll forever. +- A job stuck in `awaiting_approval` **cannot be cancelled** — `cancel_job` returns + `400 "Job cannot be cancelled"` — even though the 409's own `suggested_action` recommends + exactly that. The way out is to **deny** its pending changes. + +--- + +## Honest limitations + +- **In-memory drafts.** A restart loses anything in flight. Fine for a demo, not for a clinic. +- **The relevance check is coarse.** It is keyword-and-category matching, not clinical + judgement. It reliably catches the obviously-wrong pairing; it will not catch a fact that is + topically right and substantively weak. It exists to make a reviewer look, not to decide. +- **`unsupported_specifics` is not a hallucination detector.** It finds concrete tokens absent + from the inputs. A fabricated *qualitative* claim — "the patient's condition was severe" — + passes it untouched. +- **One fact per appeal.** Real appeals cite several. The card specified one, and the pairing + logic is what is being demonstrated. +- **No authentication, no multi-user.** Single-reviewer demo. +- **Not a medical device and not legal advice.** It drafts a letter a qualified human must read + before it goes anywhere. + +## License + +MIT. diff --git a/use-cases/aayushmishraaa/payer-appeals/app/__init__.py b/use-cases/aayushmishraaa/payer-appeals/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/use-cases/aayushmishraaa/payer-appeals/app/grounding.py b/use-cases/aayushmishraaa/payer-appeals/app/grounding.py new file mode 100644 index 0000000..bb53b44 --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/app/grounding.py @@ -0,0 +1,175 @@ +"""Does the supplied clinical fact actually address the denial reason? + +This module exists because of the specific way an appeal-drafting tool can be dangerous. + +The reviewer supplies one denial reason and one clinical fact. Sometimes the fact rebuts the +denial. Sometimes it plainly does not — a note about a patient's blood pressure does not answer +a denial for missing prior authorisation. A model asked to "write an appeal" from those two +inputs will write a fluent, confident, professional letter either way, and the way it bridges +the gap is by inventing clinical detail that nobody documented. + +A fabricated clinical assertion in a payer appeal is not a bad user experience. It is a false +statement submitted to an insurer over a clinician's name. + +So relevance is checked BEFORE drafting, and the result is shown to the reviewer either way. +The tool still drafts when the fact does not fit — refusing would just push the user to write it +by hand with no warning at all — but it drafts a letter that says what it actually has, and it +tells the reviewer plainly that the fact does not address the stated ground. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import StrEnum + + +class Relevance(StrEnum): + ADDRESSES = "addresses" + """The fact bears directly on the stated ground for denial.""" + + PARTIAL = "partial" + """Related, but does not by itself answer the ground given.""" + + DOES_NOT_ADDRESS = "does_not_address" + """No discernible connection. Drafting will not invent one.""" + + +@dataclass(frozen=True, slots=True) +class RelevanceCheck: + verdict: Relevance + reason: str + matched_theme: str = "" + + @property + def should_warn(self) -> bool: + return self.verdict is not Relevance.ADDRESSES + + +# Denial grounds and the clinical evidence that actually answers them. Data, not code: a new +# denial category is an entry here. +# +# These are deliberately coarse. The check is a guard against the obviously-wrong pairing, not +# a clinical judgement — it exists to make the reviewer look, not to decide for them. +THEMES: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = ( + ( + "prior authorisation", + ("prior auth", "precert", "pre-cert", "preauth", "authorization", "authorisation", + "carc 197", "no auth", "notification absent"), + ("authorization", "authorisation", "auth number", "approved", "reference", + "pa-", "certification", "obtained", "granted", "on file"), + ), + ( + "medical necessity", + ("medical necessity", "not medically necessary", "carc 50", "not deemed", + "experimental", "investigational"), + ("conservative", "failed", "physical therapy", "nsaid", "injection", "imaging", + "mri", "radiograph", "x-ray", "grade", "stenosis", "refractory", "symptoms", + "diagnosis", "documented", "weeks", "months", "trial of"), + ), + ( + "timely filing", + ("timely filing", "filed late", "untimely", "carc 29", "past the deadline"), + ("submitted", "date of submission", "postmark", "acknowledg", "received on", + "claim date", "resubmit"), + ), + ( + "coding", + ("coding", "invalid code", "cpt", "modifier", "unbundl", "carc 16", "ma130", + "incomplete", "invalid information", "missing information"), + ("cpt", "code", "modifier", "icd", "npi", "corrected claim", "operative report", + "procedure performed"), + ), + ( + "eligibility", + ("eligibility", "not covered", "coverage terminated", "not eligible", + "member not found", "carc 27"), + ("active coverage", "eligibility", "effective date", "enrolled", "plan end", + "verification", "270", "271"), + ), + ( + "duplicate", + ("duplicate", "already adjudicated", "carc 18"), + ("distinct", "separate", "different date", "bilateral", "modifier 59", "unrelated"), + ), +) + + +def _hits(text: str, needles: tuple[str, ...]) -> list[str]: + lowered = text.lower() + return [n for n in needles if n in lowered] + + +def check(denial_reason: str, clinical_fact: str) -> RelevanceCheck: + """Decide whether `clinical_fact` bears on `denial_reason`.""" + if not denial_reason.strip(): + return RelevanceCheck( + Relevance.DOES_NOT_ADDRESS, "No denial reason was supplied, so nothing to answer." + ) + if not clinical_fact.strip(): + return RelevanceCheck( + Relevance.DOES_NOT_ADDRESS, + "No clinical fact was supplied. An appeal with no supporting fact is a letter of " + "opinion, and this tool will not dress one up as evidence.", + ) + + for theme, denial_markers, evidence_markers in THEMES: + if not _hits(denial_reason, denial_markers): + continue + + evidence = _hits(clinical_fact, evidence_markers) + if evidence: + return RelevanceCheck( + Relevance.ADDRESSES, + f"The denial is on {theme} grounds and the clinical fact speaks to it " + f"(matched: {', '.join(evidence[:3])}).", + matched_theme=theme, + ) + + return RelevanceCheck( + Relevance.DOES_NOT_ADDRESS, + f"The denial is on {theme} grounds, but the clinical fact supplied does not " + f"appear to speak to {theme}. The letter will state the fact accurately and will " + f"NOT claim it rebuts this ground — check whether you meant to supply different " + f"documentation.", + matched_theme=theme, + ) + + # The denial did not match a known theme. Say so rather than guessing, and let the letter + # be drafted conservatively. + return RelevanceCheck( + Relevance.PARTIAL, + "The denial reason does not match a recognised category, so relevance could not be " + "assessed. The letter will pair them as given; read it before sending.", + ) + + +# -------------------------------------------------------------------------------------- +# Post-draft check: did the letter invent anything? +# -------------------------------------------------------------------------------------- + +# Clinical specifics that would be alarming in a letter if they never appeared in the input: +# a fabricated measurement, date or dosage reads as documented fact to a payer. +_SPECIFIC = re.compile( + r"\b\d+\s?(?:mg|mcg|ml|mm|cm|weeks?|months?|years?|days?|sessions?|degrees?)\b" + r"|\b\d{4}-\d{2}-\d{2}\b" + r"|\bgrade\s+[1-4iv]+\b" + r"|\b\d+\s?(?:percent|%)\b", + re.IGNORECASE, +) + + +def unsupported_specifics(draft: str, *sources: str) -> list[str]: + """Clinical specifics in the draft that appear in none of the source inputs. + + Not a hallucination detector — it cannot judge meaning. It catches the concrete, + checkable case: a number, date or dosage the letter asserts that nobody supplied. Those + are exactly the details a payer will verify and a clinician will be asked to stand behind. + """ + haystack = " ".join(sources).lower() + found: list[str] = [] + for match in _SPECIFIC.finditer(draft): + token = match.group(0) + if token.lower() not in haystack and token not in found: + found.append(token) + return found diff --git a/use-cases/aayushmishraaa/payer-appeals/app/main.py b/use-cases/aayushmishraaa/payer-appeals/app/main.py new file mode 100644 index 0000000..74b27b0 --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/app/main.py @@ -0,0 +1,265 @@ +"""Payer appeals — draft an insurance appeal letter, gated by a human. + +The flow, and the reason for each step: + + denial reason + clinical fact + -> relevance check does the fact actually answer this denial? + -> upload letter template the house format, not something the model invents + -> chat/async approval_mode=ask_every_time, so nothing self-applies + -> poll until a human decision is required + -> REVIEW every proposed change shown with before/after + -> approve or reject item by item + -> export .docx only what a person approved + +Everything here is fictional. Payers, members and clinical details are invented, and the UI +says so in a banner that cannot be dismissed. Real PHI must never be pasted into this. +""" + +from __future__ import annotations + +import os +import uuid +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from . import grounding +from .superdocs import ProposedChange, SuperDocs, SuperDocsError +from .template import appeal_template_html, build_instruction + +HERE = Path(__file__).parent +WEB = HERE.parent / "web" + +app = FastAPI( + title="Payer Appeals", + description="Draft insurance appeal letters from a denial reason and a clinical fact, " + "with human approval before anything is exported.", + version="0.1.0", +) + +# In-memory. This is a demo, and persisting fabricated clinical text buys nothing — but note +# it means a restart loses in-flight drafts. Named here rather than discovered later. +DRAFTS: dict[str, dict[str, Any]] = {} + + +class DraftRequest(BaseModel): + denial_reason: str = Field( + ..., + min_length=3, + description="The payer's stated ground for denial. Fictional only.", + examples=["CARC 197 — prior authorization/precertification absent"], + ) + clinical_fact: str = Field( + ..., + min_length=3, + description="The single clinical fact offered in rebuttal. Fictional only.", + examples=["Prior authorization PA-2026-8823 was obtained on 2026-02-19 and was valid " + "on the date of service."], + ) + patient_name: str = "Dolores Ashby" + member_id: str = "MHP-4471902" + claim_id: str = "CLM-2026-0417" + payer_name: str = "Meridian Health Plan" + provider_name: str = "Northgate Orthopaedic Associates" + + +class Decision(BaseModel): + change_id: str + approved: bool + feedback: str = "" + + +class DecideRequest(BaseModel): + decisions: list[Decision] = Field(min_length=1) + reviewer: str = "reviewer" + + +def _client() -> SuperDocs: + try: + return SuperDocs() + except SuperDocsError as exc: + raise HTTPException(503, str(exc)) from exc + + +@app.get("/api/health") +def health() -> dict[str, Any]: + return { + "ok": True, + "api_key_configured": bool(os.environ.get("SUPERDOCS_API_KEY")), + "notice": "All data in this demo is fictional. Do not enter real patient information.", + } + + +@app.post("/api/drafts") +async def create_draft(req: DraftRequest) -> dict[str, Any]: + """Draft an appeal. Returns proposed changes awaiting a human decision.""" + client = _client() + + # Relevance is checked BEFORE drafting, and the answer travels with the draft. A model + # asked to appeal a prior-auth denial using an unrelated clinical note will write a + # confident letter and invent the connection; the reviewer needs to know that up front, + # not discover it in the prose. + relevance = grounding.check(req.denial_reason, req.clinical_fact) + + draft_id = uuid.uuid4().hex[:12] + session_id = f"appeal-{draft_id}" + + template = appeal_template_html( + patient_name=req.patient_name, + member_id=req.member_id, + claim_id=req.claim_id, + payer_name=req.payer_name, + provider_name=req.provider_name, + ) + + instruction = build_instruction( + denial_reason=req.denial_reason, + clinical_fact=req.clinical_fact, + relevance=relevance, + ) + + try: + started = await client.start_edit(session_id, instruction, document_html=template) + except SuperDocsError as exc: + if exc.is_session_busy: + await client.clear_wedged_session(session_id) + started = await client.start_edit(session_id, instruction, document_html=template) + else: + raise HTTPException(502, f"SuperDocs rejected the draft request: {exc}") from exc + + job_id = started.get("job_id", "") + job = await client.wait_for_review(job_id) + + pending = [ + ProposedChange.from_api(c) + for c in (job.get("metadata") or {}).get("pending_changes") or [] + ] + + # A concrete number, date or dosage that appears in the letter but in none of the inputs + # is exactly what a payer will check and a clinician will be asked to stand behind. + drafted_text = " ".join(c.new_html for c in pending) + invented = grounding.unsupported_specifics( + drafted_text, req.denial_reason, req.clinical_fact, template + ) + + DRAFTS[draft_id] = { + "draft_id": draft_id, + "session_id": session_id, + "job_id": job_id, + "request": req.model_dump(), + "status": job.get("status"), + } + + return { + "draft_id": draft_id, + "session_id": session_id, + "job_id": job_id, + "status": job.get("status"), + "relevance": { + "verdict": relevance.verdict.value, + "reason": relevance.reason, + "warn": relevance.should_warn, + }, + "unsupported_specifics": invented, + "changes": [ + { + "change_id": c.change_id, + "operation": c.operation, + "old_html": c.old_html, + "new_html": c.new_html, + "explanation": c.explanation, + } + for c in pending + ], + } + + +@app.post("/api/drafts/{draft_id}/decide") +async def decide(draft_id: str, req: DecideRequest) -> dict[str, Any]: + """THE GATE. Approve or reject each proposed change individually.""" + draft = DRAFTS.get(draft_id) + if not draft: + raise HTTPException(404, f"no draft {draft_id!r}") + + client = _client() + try: + await client.decide( + draft["session_id"], + draft["job_id"], + [ + {"change_id": d.change_id, "approved": d.approved, "feedback": d.feedback} + for d in req.decisions + ], + ) + except SuperDocsError as exc: + raise HTTPException(502, str(exc)) from exc + + job = await client.wait_for_review(draft["job_id"]) + draft["status"] = job.get("status") + draft["decided_by"] = req.reviewer + + # A large edit comes back in several rounds. If more changes are pending, the reviewer is + # not finished — say so rather than letting them believe they have approved everything. + more = [ + ProposedChange.from_api(c) + for c in (job.get("metadata") or {}).get("pending_changes") or [] + ] + return { + "draft_id": draft_id, + "status": draft["status"], + "approved": sum(1 for d in req.decisions if d.approved), + "rejected": sum(1 for d in req.decisions if not d.approved), + "further_review_required": bool(more), + "changes": [ + { + "change_id": c.change_id, + "operation": c.operation, + "old_html": c.old_html, + "new_html": c.new_html, + "explanation": c.explanation, + } + for c in more + ], + } + + +@app.get("/api/drafts/{draft_id}/export") +async def export(draft_id: str, fmt: str = "docx") -> Response: + """Export the approved letter. Refuses while a decision is still outstanding.""" + draft = DRAFTS.get(draft_id) + if not draft: + raise HTTPException(404, f"no draft {draft_id!r}") + + if draft.get("status") == "awaiting_approval": + # The gate, enforced on the way out as well as on the way in. An export that + # silently included un-reviewed edits would make the whole review decorative. + raise HTTPException( + 409, + "This draft is still awaiting a decision. Approve or reject the outstanding " + "changes before exporting — nothing leaves here unreviewed.", + ) + + client = _client() + try: + content, filename, content_type = await client.export(draft["session_id"], fmt) + except SuperDocsError as exc: + raise HTTPException(502, str(exc)) from exc + + return Response( + content=content, + media_type=content_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(WEB / "index.html") + + +if WEB.exists(): + app.mount("/static", StaticFiles(directory=str(WEB)), name="static") diff --git a/use-cases/aayushmishraaa/payer-appeals/app/superdocs.py b/use-cases/aayushmishraaa/payer-appeals/app/superdocs.py new file mode 100644 index 0000000..eab5afe --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/app/superdocs.py @@ -0,0 +1,212 @@ +"""Thin SuperDocs client for the appeal flow. + +Four calls: upload, edit with approval required, approve, export. Written directly against the +REST API rather than pulled from a package, so this app stays a readable example of the +contract rather than a demonstration of a dependency. + +Three things learned against the live API and encoded here so they do not have to be +rediscovered: + + * The upload field is `file_base64`, not `content_base64`. + * A large edit arrives in SEVERAL approval rounds; the job returns to `awaiting_approval` + after each. Approving once and waiting for completion polls forever. + * A job stuck in `awaiting_approval` cannot be cancelled — `cancel_job` returns 400 — so a + wedged session is cleared by DENYING its pending changes. +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +from dataclasses import dataclass +from typing import Any + +import httpx + +BASE_URL = os.environ.get("SUPERDOCS_BASE_URL", "https://api.superdocs.app") +TERMINAL = {"completed", "failed", "cancelled"} + + +class SuperDocsError(RuntimeError): + def __init__(self, message: str, status: int = 0, body: str = ""): + super().__init__(message) + self.status = status + self.body = body + + @property + def is_session_busy(self) -> bool: + return "session_busy" in self.body + + +@dataclass(frozen=True, slots=True) +class ProposedChange: + change_id: str + operation: str + old_html: str + new_html: str + explanation: str + + @classmethod + def from_api(cls, raw: dict[str, Any]) -> ProposedChange: + return cls( + change_id=raw.get("change_id", ""), + operation=raw.get("operation", "edit"), + # NOT double-parsed. On the polling path these are real strings; the second parse + # the docs describe applies to the SSE/intermediate_responses path only. + old_html=raw.get("old_html") or "", + new_html=raw.get("new_html") or "", + explanation=raw.get("ai_explanation") or "", + ) + + +class SuperDocs: + def __init__(self, api_key: str | None = None, *, timeout: float = 300.0): + self.api_key = api_key or os.environ.get("SUPERDOCS_API_KEY", "") + if not self.api_key: + raise SuperDocsError( + "SUPERDOCS_API_KEY is not set. Copy .env.example to .env and add your key." + ) + self.timeout = timeout + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "User-Agent": "payer-appeals/0.1", + } + + async def _post(self, client: httpx.AsyncClient, path: str, body: dict[str, Any]) -> Any: + response = await client.post(BASE_URL + path, json=body, headers=self._headers()) + if response.status_code >= 400: + raise SuperDocsError( + f"POST {path} -> {response.status_code}: {response.text[:400]}", + response.status_code, + response.text, + ) + return response.json() + + async def _get(self, client: httpx.AsyncClient, path: str) -> Any: + response = await client.get(BASE_URL + path, headers=self._headers()) + if response.status_code >= 400: + raise SuperDocsError( + f"GET {path} -> {response.status_code}: {response.text[:400]}", + response.status_code, + response.text, + ) + return response.json() + + # ------------------------------------------------------------------------------ + + async def upload(self, session_id: str, filename: str, content: bytes) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=self.timeout) as client: + return await self._post( + client, + "/v1/documents/upload-base64", + { + "filename": filename, + # `file_base64`. Not `content_base64` — that returns a 422 whose message + # is the only place the right name appears. + "file_base64": base64.b64encode(content).decode(), + "session_id": session_id, + "return_html": True, + }, + ) + + async def start_edit(self, session_id: str, instruction: str, document_html: str | None = None): + async with httpx.AsyncClient(timeout=self.timeout) as client: + body: dict[str, Any] = { + "message": instruction, + "session_id": session_id, + "approval_mode": "ask_every_time", + } + if document_html: + body["document_html"] = document_html + return await self._post(client, "/v1/chat/async", body) + + async def get_job(self, job_id: str) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=self.timeout) as client: + return await self._get(client, f"/v1/jobs/{job_id}") + + async def wait_for_review(self, job_id: str, *, poll: float = 2.0, limit: int = 150): + """Poll until the job wants a decision or finishes. + + `limit` is a real bound rather than a formality: without one, a job that never reaches + a terminal state polls forever, which is exactly what happened before the multi-round + behaviour was understood. + """ + for _ in range(limit): + job = await self.get_job(job_id) + status = job.get("status", "") + if status == "awaiting_approval" or status in TERMINAL: + return job + await asyncio.sleep(poll) + raise SuperDocsError(f"job {job_id} never settled after {limit} polls") + + async def decide(self, session_id: str, job_id: str, decisions: list[dict[str, Any]]): + """Approve/reject each change. `decisions` is [{change_id, approved, feedback}].""" + if not decisions: + raise SuperDocsError("no decisions supplied; refusing to guess") + async with httpx.AsyncClient(timeout=self.timeout) as client: + return await self._post( + client, + f"/v1/chat/{session_id}/approve", + { + "job_id": job_id, + # Required even for batch decisions. Omitting it is a 422 that does not + # say which field is missing. + "approved": True, + "changes": decisions, + }, + ) + + async def export(self, session_id: str, fmt: str = "docx") -> tuple[bytes, str, str]: + """Returns (bytes, filename, content_type). Exports do not consume operations.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + BASE_URL + "/v1/documents/export", + json={"session_id": session_id, "format": fmt}, + headers={"Authorization": f"Bearer {self.api_key}"}, + ) + if response.status_code >= 400: + raise SuperDocsError( + f"export -> {response.status_code}: {response.text[:300]}", + response.status_code, + response.text, + ) + disposition = response.headers.get("content-disposition", "") + filename = f"appeal.{fmt}" + if "filename=" in disposition: + filename = disposition.split("filename=")[1].split(";")[0].strip('"') + return ( + response.content, + filename, + response.headers.get("content-type", "application/octet-stream"), + ) + + async def clear_wedged_session(self, session_id: str) -> int: + """Free a session whose previous job was abandoned mid-review. + + Denies rather than cancels: `cancel_job` returns 400 "Job cannot be cancelled" on an + `awaiting_approval` job, even though the 409's own suggested_action recommends it. + """ + async with httpx.AsyncClient(timeout=self.timeout) as client: + payload = await self._get(client, f"/v1/sessions/{session_id}/jobs") + cleared = 0 + for job in payload.get("jobs") or []: + if job.get("status") in TERMINAL: + continue + pending = (job.get("metadata") or {}).get("pending_changes") or [] + if not pending: + continue + await self.decide( + session_id, + job["job_id"], + [ + {"change_id": c["change_id"], "approved": False, + "feedback": "abandoned by a previous session"} + for c in pending + ], + ) + cleared += 1 + return cleared diff --git a/use-cases/aayushmishraaa/payer-appeals/app/template.py b/use-cases/aayushmishraaa/payer-appeals/app/template.py new file mode 100644 index 0000000..28604a3 --- /dev/null +++ b/use-cases/aayushmishraaa/payer-appeals/app/template.py @@ -0,0 +1,117 @@ +"""The appeal letter template, and the instruction that fills it. + +The template is a real document with real structure, uploaded as the starting point rather +than asked for from the model. That matters for two reasons: appeal letters have a house +format that a compliance team has already signed off, and starting from a document means the +model performs *targeted edits on named sections* — which is the thing SuperDocs is good at — +instead of generating prose from nothing, which is the thing everything is good at. + +The instruction is where the honesty rules live. They are written as constraints on what the +letter may assert, not as style guidance, because the failure that matters here is a letter +that reads beautifully and states something nobody documented. +""" + +from __future__ import annotations + +from .grounding import Relevance, RelevanceCheck + + +def appeal_template_html( + *, + patient_name: str, + member_id: str, + claim_id: str, + payer_name: str, + provider_name: str, +) -> str: + """The house appeal-letter format, as a document to be edited.""" + return f""" +
To: {payer_name}, Appeals Unit
+From: {provider_name}
+Re: Claim {claim_id} — {patient_name}, Member ID {member_id}
+ +[The ground on which the claim was denied, stated back to the payer in their own terms.]
+ +[The specific documented clinical fact that answers that ground, and how it answers it.]
+ +[What is enclosed, and what is not.]
+ +[What the provider is asking the payer to do.]
+ +Respectfully submitted,
{provider_name}
One denial reason, one clinical fact, one reviewed letter.
+ + + ++ + +
+