Skip to main content
Glama
ayushgupta94

claims-coverage-assessment-assistant

by ayushgupta94
README.md
# Claims Coverage Assessment Assistant

An AI assistant that reviews an insurance claim against policy documents and
returns a structured coverage decision: coverage outcome, supporting policy
clauses, a confidence score, a fraud risk assessment, and a flag for cases
that need human review.

Single FastAPI service. A tool-calling LLM agent, backed by RAG and four
tools exposed over MCP, running against MongoDB (Atlas locally / Azure
Cosmos DB for MongoDB vCore in production). No stub/offline mode for the
LLM or embeddings -- one real code path, local and in production.

---

## 1. Architecture

```
                          ┌───────────────────────────────┐
   POST /claims/assess ──▶│          FastAPI app            │
                          │  ┌───────────────────────────┐  │
                          │  │   Agent Orchestrator        │  │
                          │  │   (LLM tool-calling loop)   │  │
                          │  └─────────────┬───────────────┘  │
                          │                │ real MCP client   │
                          │                │ (loopback HTTP)   │
                          │  ┌─────────────▼───────────────┐  │
                          │  │   MCP server, mounted /mcp   │  │
                          │  │   exposes 4 tools:           │  │
                          │  │   - retrieve_policy_clauses  │  │
                          │  │   - lookup_claim_history      │  │
                          │  │   - check_coverage_rules      │  │
                          │  │   - score_fraud_risk          │  │
                          │  └─────────────┬───────────────┘  │
                          └────────────────┼────────────────────┘
                                           ▼
                          MongoDB (Atlas locally / Cosmos DB for
                          MongoDB vCore in production)
```

- **One process, one container.** The MCP server is not a separate
  deployable service -- it's mounted at `/mcp` inside the same FastAPI app.
- **The agent is a real MCP client**, not a direct function-call shortcut:
  it calls `initialize()` → `call_tool()` over loopback HTTP against this
  app's own `/mcp` endpoint. Any external MCP client could reuse the same
  four tools the same way.
- **LLM:** OpenAI or Azure OpenAI (`LLM_PROVIDER`), real function-calling,
  no stub. **Embeddings:** OpenAI (`text-embedding-3-small`), no TF-IDF.

---

## 2. Data model

Product catalog is kept separate from what a customer actually bought --
avoids duplicating identical rules/clauses per customer, and lets policy
wording change over time without breaking already-issued policies.

| Collection | Purpose | Key fields |
|---|---|---|
| `product_versions` | Reusable product rules + wording (e.g. `AUTO-GOLD-V1`) | `product_version_id`, `policy_type`, `excluded_claim_types`, `waiting_period_days` |
| `issued_policies` | One customer's actual purchased policy | `policy_id`, `customer_id`, `product_version_id`, `sum_insured`, `inception_date` |
| `policy_clauses` | RAG corpus, shared per product version | `clause_id`, `product_version_id`, `text`, `embedding` |
| `claims` | Claims across their whole lifecycle -- **also serves as "claim history"** | `claim_id`, `policy_id`, `status`, `decision` |

**`claims` is one collection, not two.** A claim is the same entity
throughout its lifecycle (`submitted → under_review → approved/rejected`);
"claim history" is a query over this same collection for other claims on
the same policy -- **excluding the claim currently being assessed**, since
it's persisted with `status=submitted` *before* the agent loop runs and
would otherwise show up in its own history/fraud-frequency lookup:

```js
{ "policy_id": policy_id, "claim_id": { "$ne": current_claim_id } }
```

```
CLM-001
submitted
   │
under_review          (requires_human_review = true)
   │
approved / rejected   (based on coverage_outcome)
```

---

## 3. End-to-end request flow

```
1.  POST /claims/assess  → validated against ClaimRequest
2.  Route → ClaimAssessmentService.assess()
3.      a. get_issued_policy(policy_id)         -- 404 if missing
        b. validate claim.customer_id == policy.customer_id
        c. save_claim()  -- persisted with status=submitted, BEFORE the
                             agent loop, because tools 3 & 4 look the
                             claim up by claim_id, not by receiving it
                             as an argument
4.  Orchestrator.run(claim) -- loop, max 6 iterations:
        a. LLM decides: call a tool, or return final JSON
        b. tool call → real MCP call (initialize → call_tool) → one of:
             - retrieve_policy_clauses(query, policy_id)
                 → resolves policy_id → product_version_id → RAG search
             - lookup_claim_history(claim_id, lookback_days?)
                 → resolves claim → policy_id, excludes claim_id itself
             - check_coverage_rules(claim_id)
                 → deterministic: exclusions/waiting period from the
                   product version, sum insured/inception from the
                   issued policy -- NO LLM in this decision
             - score_fraud_risk(claim_id)
                 → deterministic weighted heuristic -- NO LLM here either
        c. result fed back into the conversation, loop continues
5.  LLM returns final JSON → parsed into CoverageDecision
        - fraud_risk is ALWAYS re-sourced from the actual tool result,
          never trusted from the LLM's own retelling
        - malformed/missing JSON → fallback assembly straight from
          whatever tool results were gathered (requires_human_review
          forced true in that case)
6.  save_decision() -- adds `decision` + advances `status`
7.  CoverageDecision → JSON response
```

---

## 4. Component reference

| Component | File(s) | Role |
|---|---|---|
| Config | `config.py` | All env vars, one place, no `os.environ` elsewhere |
| Domain models | `domain/models.py` | Shared Pydantic contracts across every layer |
| Repositories | `db/repositories/*.py` | Only place that talks Mongo query syntax |
| RAG chunking | `rag/chunking.py` | Product-version clauses → citable chunks |
| RAG embeddings | `rag/embeddings.py` | OpenAI `text-embedding-3-small`, one implementation |
| RAG indexer | `rag/indexer.py` | Ingestion pipeline (`scripts/seed_db.py` calls this) |
| RAG retriever | `rag/retriever.py` | Query-time cosine similarity, scoped to one `product_version_id` |
| 4 tools | `tools/*.py` | Plain async functions, registered on the MCP server |
| MCP server | `mcp_server/server.py` | FastMCP, mounted at `/mcp`, wraps every tool return in one JSON object |
| LLM client | `agent/llm_client.py` | OpenAI / Azure OpenAI, real function-calling |
| Prompts | `agent/prompts.py` | System prompt + tool schemas (what the model is told) |
| Orchestrator | `agent/orchestrator.py` | The loop; `ToolExecutor` = the real MCP client |
| Service | `services/claim_assessment_service.py` | The one use case: assess a claim |
| API | `api/routes/*.py`, `api/deps.py` | Route handlers (no logic) + dependency wiring |
| App factory | `main.py` | Builds Mongo, mounts MCP, registers routes |

**Design decisions, briefly:**

- **No LangChain/LangGraph.** The whole loop is ~150 lines of plain Python
  in `orchestrator.py` -- fully explainable, no hidden control flow, and
  this project has no multi-agent/graph/branching need that would justify
  the abstraction.
- **No multi-agent / A2A.** One model, one loop, four tools -- sufficient
  for "assess one claim," and avoids complexity with no corresponding need.
- **Coverage rules and fraud scoring are deterministic code, not LLM
  calls.** Eligibility decisions must be auditable; the LLM only combines
  and explains tool outputs, never decides them.
- **Fraud scoring is a transparent weighted heuristic, not a trained ML
  model** -- explicitly POC-level; interface (`claim_id` in, `FraudRiskResult`
  out) would stay the same if swapped for a real model later.
- **Brute-force cosine similarity in Python, not a vector index.** Fast
  enough at POC scale (hundreds of clauses); the retriever's return type is
  the isolation boundary if a native vector index is added later.
- **MCP is genuinely used, not decorative.** The orchestrator is a real MCP
  client calling the mounted server over loopback HTTP -- same protocol an
  external client would use, not a shortcut.

---

## 5. Setup and run (no Docker required)

```bash
# 1. Database -- MongoDB Atlas free tier (mongodb.com/atlas), or local mongod
cp .env.example .env
# edit .env: MONGO_URI, OPENAI_API_KEY (required -- no offline mode),
# APP_API_KEY (a random key you generate, e.g. `openssl rand -hex 16`)

# 2. Install
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# 3. Seed (product versions + issued policies + prior claims)
python scripts/seed_db.py

# 4. Run
cd src && uvicorn app.main:app --reload
```

- API docs: `http://localhost:8000/docs`
- MCP endpoint: `http://localhost:8000/mcp/`
- Health check: `http://localhost:8000/health`

### Tests

```bash
pytest tests/ -v
```

25 tests, no external services or API keys needed -- `mongomock-motor` for
persistence, hand-written `FakeLLMClient`/`FakeToolExecutor` doubles for
orchestrator unit tests, and one integration test that spins up a **real**
MCP server (real protocol, real tools) with only the LLM and database
mocked.

### Deployment

`docker/Dockerfile` builds the single-container image.
`.github/workflows/deploy.yml`: tests on every push; on `main`, builds,
pushes to Azure Container Registry, deploys to Azure Container Apps.
Runtime config (`MONGO_URI`, `OPENAI_API_KEY`, etc.) is set as environment
variables on the Container App itself, not in the workflow.

---

## 6. Testing / cURL examples

Seed the database first (`python scripts/seed_db.py`). All examples use
the seeded `POL-AUTO-1001` / `POL-HEALTH-2001` policies. Always include
`filed_at` explicitly, so the fraud "claim frequency" signal lines up with
the seeded 2024-dated history correctly.

**Test 1 — Clean claim: fully covered, low risk**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{
  "claim_id": "TEST-1", "policy_id": "POL-AUTO-1001", "customer_id": "CUST-001",
  "claim_type": "collision", "description": "Rear-ended at a traffic light",
  "amount": 45000, "incident_date": "2024-06-01T00:00:00Z", "filed_at": "2024-06-02T00:00:00Z"
}'
```
Expected: `coverage_outcome: "covered"`, `fraud_risk.risk_level: "low"`, `requires_human_review: false`.

**Test 2 — Excluded claim type → rejected**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{
  "claim_id": "TEST-2", "policy_id": "POL-AUTO-1001", "customer_id": "CUST-001",
  "claim_type": "racing", "description": "Damage during a street race",
  "amount": 30000, "incident_date": "2024-06-01T00:00:00Z", "filed_at": "2024-06-02T00:00:00Z"
}'
```
Expected: `coverage_outcome: "not_covered"`, `requires_human_review: true`.

**Test 3 — Waiting period violation**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{
  "claim_id": "TEST-3", "policy_id": "POL-AUTO-1001", "customer_id": "CUST-001",
  "claim_type": "collision", "description": "Collision shortly after buying the policy",
  "amount": 10000, "incident_date": "2024-01-20T00:00:00Z", "filed_at": "2024-01-21T00:00:00Z"
}'
```
Expected: `coverage_outcome: "not_covered"` (day 5 of a 15-day waiting period), `fraud_risk.risk_level: "medium"`.

**Test 4 — Amount exceeds sum insured**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{
  "claim_id": "TEST-4", "policy_id": "POL-AUTO-1001", "customer_id": "CUST-001",
  "claim_type": "collision", "description": "Major collision, vehicle totaled",
  "amount": 900000, "incident_date": "2024-06-01T00:00:00Z", "filed_at": "2024-06-02T00:00:00Z"
}'
```
Expected: `coverage_outcome: "not_covered"` (900,000 > sum insured 800,000), `fraud_risk.risk_level: "medium"`.

**Test 5 — Covered by rules, but HIGH fraud risk (best one to demo live)**
```bash
curl -X POST "https://claims-assistant-app.wonderfuldesert-4bb884ca.centralindia.azurecontainerapps.io/claims/assess" -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d "{\"claim_id\":\"TEST-5\",\"policy_id\":\"POL-AUTO-1001\",\"customer_id\":\"CUST-001\",\"claim_type\":\"theft\",\"description\":\"Vehicle stolen from parking lot\",\"amount\":600000,\"incident_date\":\"2024-02-01T00:00:00Z\",\"filed_at\":\"2024-02-02T00:00:00Z\"}"
```
Expected: `coverage_outcome: "covered"` but `fraud_risk.risk_level: "high"` (high-amount + early-claim signals both fire), `requires_human_review: true`. Shows coverage and fraud risk are independent axes.

**Test 6 — Health policy, different waiting period**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{
  "claim_id": "TEST-6", "policy_id": "POL-HEALTH-2001", "customer_id": "CUST-002",
  "claim_type": "hospitalization", "description": "Emergency admission for surgery",
  "amount": 50000, "incident_date": "2023-07-01T00:00:00Z", "filed_at": "2023-07-02T00:00:00Z"
}'
```
Expected: `coverage_outcome: "not_covered"` (day 30 of a 90-day waiting period).

**Test 7 — Unknown policy → 404**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{
  "claim_id": "TEST-7", "policy_id": "POL-DOES-NOT-EXIST", "customer_id": "CUST-999",
  "claim_type": "collision", "description": "Test", "amount": 1000,
  "incident_date": "2024-06-01T00:00:00Z", "filed_at": "2024-06-02T00:00:00Z"
}'
```
Expected: HTTP `404`, `{"error_code": "policy_not_found", ...}`.

**Test 8 — Invalid payload → 422**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -H "X-API-Key: $APP_API_KEY" -d '{"claim_id": "X"}'
```
Expected: HTTP `422`.

**Test 9 — Missing or invalid API key → 401**
```bash
curl -X POST http://localhost:8000/claims/assess -H "Content-Type: application/json" -d '{
  "claim_id": "TEST-AUTH", "policy_id": "POL-AUTO-1001", "customer_id": "CUST-001",
  "claim_type": "collision", "description": "Auth test", "amount": 1000,
  "incident_date": "2024-06-01T00:00:00Z", "filed_at": "2024-06-02T00:00:00Z"
}'
```
Expected: HTTP `401`, `{"error_code": "unauthorized", ...}`.

**Test 10 — Health check**
```bash
curl http://localhost:8000/health
```
Expected: `{"status": "ok", "mongo_connected": true}`. No API key required.

---

## 7. Environment variables

| Variable | Default | Purpose |
|---|---|---|
| `APP_API_KEY` | — | Required to call `/claims/*`; a random key you generate (e.g. `openssl rand -hex 16`), sent by clients in `X-API-Key`. Unset = every request rejected |
| `MONGO_URI` | `mongodb://localhost:27017` | Atlas / local / Cosmos DB vCore connection string |
| `MONGO_DB_NAME` | `claims_assistant` | Database name |
| `OPENAI_API_KEY` | — | Required always (RAG embeddings; also default LLM provider) |
| `LLM_PROVIDER` | `openai` | `openai` \| `azure_openai` |
| `OPENAI_MODEL` | `gpt-4o-mini` | Model for the agent loop |
| `AZURE_OPENAI_ENDPOINT` / `AZURE_OPENAI_API_KEY` / `AZURE_OPENAI_DEPLOYMENT` | — | Required if `LLM_PROVIDER=azure_openai` |
| `RAG_TOP_K` | `3` | Clauses retrieved per query |
| `FRAUD_HIGH_AMOUNT_THRESHOLD` | `500000` | Fraud signal: high claim amount |
| `FRAUD_EARLY_CLAIM_DAYS_THRESHOLD` | `30` | Fraud signal: claim shortly after inception |
| `FRAUD_FREQUENCY_LOOKBACK_DAYS` | `365` | Fraud signal: frequency window |
| `FRAUD_FREQUENCY_CLAIM_COUNT_THRESHOLD` | `3` | Fraud signal: claim count trigger |

See `.env.example` for the full list.