Spaces:
Runtime error
Runtime error
Mayank Patel commited on
Commit ·
5c32ed1
1
Parent(s): 5366a94
Initial deployment: UHC Medical Policy Chatbot
Browse filesRAG chatbot for UnitedHealthcare medical policies using:
- MedEmbed (1024-dim) for semantic retrieval
- Qdrant Cloud for vector search
- Groq API (Llama 3.1 8B) for answer generation
- Streamlit web UI with streaming and source citations
Made-with: Cursor
- .env.example +8 -0
- README.md +226 -6
- app.py +194 -0
- chatbot/__init__.py +0 -0
- chatbot/cli.py +189 -0
- chatbot/config.py +31 -0
- chatbot/llm.py +129 -0
- chatbot/llm_groq.py +72 -0
- chatbot/prompts.py +89 -0
- chatbot/retriever.py +169 -0
- embedding/scripts/__init__.py +0 -0
- embedding/scripts/config.py +33 -0
- embedding/scripts/embed_chunks.py +128 -0
- embedding/scripts/search.py +155 -0
- embedding/scripts/store_qdrant.py +170 -0
- embedding/scripts/test_retrieval.py +386 -0
- requirements.txt +24 -0
- scraper/create_rag_chunks.py +278 -0
- scraper/download_policies.py +67 -0
- scraper/extract_pdf_text.py +403 -0
.env.example
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Qdrant - local docker (default)
|
| 2 |
+
QDRANT_HOST=localhost
|
| 3 |
+
QDRANT_PORT=6333
|
| 4 |
+
QDRANT_COLLECTION=uhc_policies
|
| 5 |
+
|
| 6 |
+
# Qdrant Cloud (uncomment to use instead of local)
|
| 7 |
+
# QDRANT_URL=https://your-cluster.qdrant.io
|
| 8 |
+
# QDRANT_API_KEY=your-api-key-here
|
README.md
CHANGED
|
@@ -1,12 +1,232 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
-
sdk:
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: UHC Medical Policy Chatbot
|
| 3 |
+
emoji: 🏥
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: purple
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.44.1
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# UHC Medical Policy Chatbot
|
| 13 |
+
|
| 14 |
+
A RAG-powered chatbot that answers questions about UnitedHealthcare (UHC) medical policies. Built for doctors, hospital staff, and insurance coordinators who need accurate, cited answers about coverage criteria, CPT/HCPCS codes, and medical necessity requirements.
|
| 15 |
+
|
| 16 |
+
## Hosted Chatbot
|
| 17 |
+
|
| 18 |
+
**URL:** [https://huggingface.co/spaces/mxp1404/uhc-policy-chatbot](https://huggingface.co/spaces/mxp1404/uhc-policy-chatbot)
|
| 19 |
+
|
| 20 |
+
### How to Use — Step-by-Step
|
| 21 |
+
|
| 22 |
+
1. Open the link above in your browser.
|
| 23 |
+
2. Wait for the model to load (first visit takes ~30 seconds for MedEmbed to initialize).
|
| 24 |
+
3. Type your question in the chat input at the bottom — for example:
|
| 25 |
+
- *"Is bariatric surgery covered for BMI over 40?"*
|
| 26 |
+
- *"What documentation is needed for gender-affirming surgery?"*
|
| 27 |
+
- *"Are intrapulmonary percussive ventilation devices covered for home use?"*
|
| 28 |
+
4. The chatbot will search relevant policy chunks, then stream an answer with citations.
|
| 29 |
+
5. Click **"📚 Sources"** below each answer to see the exact policy sections used.
|
| 30 |
+
6. Use **"🗑️ Clear conversation"** in the sidebar to start a new session.
|
| 31 |
+
|
| 32 |
+
The chatbot only answers from official UHC policy documents — it will tell you if it doesn't have enough information rather than guessing.
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Architecture
|
| 37 |
+
|
| 38 |
+
### High-Level Design (HLD)
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
┌─────────────┐ ┌──────────────────────────────────────────────┐
|
| 42 |
+
│ Browser │────▶│ Streamlit App (HuggingFace Spaces) │
|
| 43 |
+
│ (User) │◀────│ │
|
| 44 |
+
└─────────────┘ │ ┌─────────────┐ ┌─────────────────────┐ │
|
| 45 |
+
│ │ MedEmbed │ │ Groq API │ │
|
| 46 |
+
│ │ (1024-dim) │ │ Llama 3.1 8B │ │
|
| 47 |
+
│ │ cached RAM │ │ 560 tok/s │ │
|
| 48 |
+
│ └──────┬──────┘ └──────▲──────────────┘ │
|
| 49 |
+
│ │ │ │
|
| 50 |
+
│ ▼ │ │
|
| 51 |
+
│ ┌─────────────┐ context + query │
|
| 52 |
+
│ │ Qdrant Cloud│────────────┘ │
|
| 53 |
+
│ │ (vectors) │ │
|
| 54 |
+
│ └─────────────┘ │
|
| 55 |
+
└──────────────────────────────────────────────┘
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
**Data flow for each query:**
|
| 59 |
+
|
| 60 |
+
1. User types a question in the Streamlit chat interface
|
| 61 |
+
2. The query is encoded into a 1024-dimensional vector using **MedEmbed** (loaded once, cached in memory)
|
| 62 |
+
3. The vector is sent to **Qdrant Cloud** for similarity search — returns top-K policy chunks with metadata
|
| 63 |
+
4. Retrieved chunks are deduplicated, truncated, and formatted into a context block
|
| 64 |
+
5. The context + query + system prompt are sent to **Groq API** (Llama 3.1 8B) for answer generation
|
| 65 |
+
6. The response is streamed token-by-token back to the user with source citations
|
| 66 |
+
|
| 67 |
+
### Low-Level Design (LLD)
|
| 68 |
+
|
| 69 |
+
#### Project Structure
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
uhc/
|
| 73 |
+
├── app.py # Streamlit web UI entry point
|
| 74 |
+
├── requirements.txt # Python dependencies
|
| 75 |
+
├── .env.example # Environment variable template
|
| 76 |
+
│
|
| 77 |
+
├── chatbot/ # Chatbot application layer
|
| 78 |
+
│ ├── config.py # Centralized config (LLM, retrieval, env vars)
|
| 79 |
+
│ ├── retriever.py # PolicyRetriever: MedEmbed + Qdrant wrapper
|
| 80 |
+
│ ├── llm_groq.py # Groq API client (deployed)
|
| 81 |
+
│ ├── llm.py # Ollama client (local dev)
|
| 82 |
+
│ ├── prompts.py # System prompt, context formatting, deduplication
|
| 83 |
+
│ └── cli.py # CLI interface (local dev)
|
| 84 |
+
│
|
| 85 |
+
├── embedding/ # Embedding pipeline
|
| 86 |
+
│ └── scripts/
|
| 87 |
+
│ ├── config.py # Embedding model + Qdrant connection config
|
| 88 |
+
│ ├── embed_chunks.py # Generate embeddings from RAG chunks
|
| 89 |
+
│ ├── store_qdrant.py # Upsert embeddings into Qdrant with payload indexes
|
| 90 |
+
│ ├── search.py # Standalone search CLI for testing
|
| 91 |
+
│ └── test_retrieval.py # Batch retrieval evaluation (10 test cases)
|
| 92 |
+
│
|
| 93 |
+
└── scraper/ # Data ingestion pipeline
|
| 94 |
+
├── download_policies.py # Scrape PDFs from UHC website
|
| 95 |
+
├── extract_pdf_text.py # PDF → structured sections with metadata
|
| 96 |
+
├── create_rag_chunks.py # Section-aware semantic chunking
|
| 97 |
+
└── data/processed/
|
| 98 |
+
├── extracted_sections.json # Extracted text per policy/section
|
| 99 |
+
└── rag_chunks.json # Final RAG chunks with metadata
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
#### Module Design
|
| 103 |
+
|
| 104 |
+
**`chatbot/retriever.py` — PolicyRetriever**
|
| 105 |
+
- Loads `abhinand/MedEmbed-large-v0.1` (1024-dim medical embeddings) via `sentence-transformers`
|
| 106 |
+
- Connects to Qdrant Cloud; supports both cloud and local Qdrant
|
| 107 |
+
- Encodes queries → cosine similarity search → returns `ChunkResult` dataclasses
|
| 108 |
+
- Filters out low-value sections (References, Application) that pollute results
|
| 109 |
+
- Boosts Coverage Rationale chunks (+0.02 score) so authoritative coverage statements always surface
|
| 110 |
+
- Retry logic with exponential backoff for transient Qdrant errors
|
| 111 |
+
|
| 112 |
+
**`chatbot/prompts.py` — Prompt Engineering**
|
| 113 |
+
- System prompt enforces: answer from context only, 2–4 bullet points, cite sources, coverage-awareness
|
| 114 |
+
- `deduplicate_chunks()` keeps highest-scoring chunk per (policy, section) pair
|
| 115 |
+
- `format_context()` truncates each chunk to 800 chars at sentence boundaries, caps total at 6000 chars
|
| 116 |
+
- Coverage Rationale is explicitly marked as authoritative for coverage decisions
|
| 117 |
+
|
| 118 |
+
**`chatbot/llm_groq.py` — GroqClient**
|
| 119 |
+
- Uses `groq` Python SDK with streaming chat completions
|
| 120 |
+
- Graceful rate-limit handling (Groq free tier: 250K TPM)
|
| 121 |
+
- Same `chat_stream()` / `chat()` interface as the Ollama client for interchangeability
|
| 122 |
+
|
| 123 |
+
**`scraper/extract_pdf_text.py` — PDF Extraction**
|
| 124 |
+
- Paragraph-level extraction using `pdfplumber` (not line-by-line)
|
| 125 |
+
- Robust header/footer/sidebar removal with regex patterns
|
| 126 |
+
- Structured metadata parsing: policy number, effective date, plan type, document type
|
| 127 |
+
- Table extraction support; skips boilerplate sections and HTML-disguised files
|
| 128 |
+
|
| 129 |
+
**`scraper/create_rag_chunks.py` — Semantic Chunking**
|
| 130 |
+
- Section-aware chunking: different strategies per section type
|
| 131 |
+
- Coverage Rationale → criteria-based splitting
|
| 132 |
+
- Applicable Codes → table-aware chunking
|
| 133 |
+
- Clinical Evidence → study-based splitting
|
| 134 |
+
- Others → paragraph-aware with sentence-boundary overlap
|
| 135 |
+
- Rich metadata per chunk: policy name, section, plan type, page range, provider
|
| 136 |
+
- Deterministic chunk IDs for deduplication during re-indexing
|
| 137 |
+
|
| 138 |
+
**`embedding/scripts/embed_chunks.py` — Embedding Generation**
|
| 139 |
+
- Prepends metadata to chunk text before encoding for better retrieval
|
| 140 |
+
- Batch processing (32 chunks at a time) with GPU/MPS/CPU auto-detection
|
| 141 |
+
- Saves to `.npz` for efficient storage and reloading
|
| 142 |
+
|
| 143 |
+
**`embedding/scripts/store_qdrant.py` — Vector Storage**
|
| 144 |
+
- Creates Qdrant collection with cosine distance
|
| 145 |
+
- Upserts embeddings with full metadata payloads
|
| 146 |
+
- Creates payload indexes on `section`, `policy_name`, `plan_type`, `doc_type`, `provider` for efficient filtered search
|
| 147 |
+
|
| 148 |
+
#### Edge Cases Handled
|
| 149 |
+
|
| 150 |
+
| Edge Case | Handling |
|
| 151 |
+
|---|---|
|
| 152 |
+
| Empty / whitespace query | Warning message, no API call |
|
| 153 |
+
| Qdrant connection failure | Retry with exponential backoff (3 attempts) |
|
| 154 |
+
| Groq rate limit (429) | Caught and shown as user-friendly message |
|
| 155 |
+
| No relevant chunks found | "I don't have enough policy information" |
|
| 156 |
+
| Coverage vs. evidence conflict | System prompt + Coverage Rationale boost ensures correct answer |
|
| 157 |
+
| Very long conversation | History trimmed to last 3 turns |
|
| 158 |
+
| Model loading on first visit | Spinner shown; cached with `st.cache_resource` |
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Extending for Other Insurance Providers
|
| 163 |
+
|
| 164 |
+
The system is designed for multi-provider extensibility:
|
| 165 |
+
|
| 166 |
+
1. **Data layer**: Each chunk in Qdrant has a `provider` field (currently `"UnitedHealthcare"`). Adding a new provider means running the same pipeline with a new provider slug — chunks coexist in the same collection.
|
| 167 |
+
|
| 168 |
+
2. **Scraper**: `scraper/download_policies.py` can be adapted for any provider's website. The extractor and chunker handle standard medical policy PDF structures.
|
| 169 |
+
|
| 170 |
+
3. **Embedding**: The same MedEmbed model works for all medical content. New provider chunks are embedded and upserted alongside existing ones.
|
| 171 |
+
|
| 172 |
+
4. **Retrieval**: Add a `provider_filter` parameter to narrow results by provider, or query across all providers simultaneously.
|
| 173 |
+
|
| 174 |
+
5. **UI**: Add a provider selector dropdown in the Streamlit sidebar — one line change.
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
# Example: adding Aetna
|
| 178 |
+
retriever.retrieve(query, provider_filter="aetna")
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
## Local Development Setup
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
# 1. Clone the repo
|
| 187 |
+
git clone https://github.com/<your-username>/uhc-policy-chatbot.git
|
| 188 |
+
cd uhc-policy-chatbot
|
| 189 |
+
|
| 190 |
+
# 2. Create virtual environment
|
| 191 |
+
python3 -m venv venv
|
| 192 |
+
source venv/bin/activate
|
| 193 |
+
|
| 194 |
+
# 3. Install dependencies
|
| 195 |
+
pip install -r requirements.txt
|
| 196 |
+
|
| 197 |
+
# 4. Configure environment variables
|
| 198 |
+
cp .env.example .env
|
| 199 |
+
# Edit .env with your Qdrant and Groq API keys
|
| 200 |
+
|
| 201 |
+
# 5. Run the Streamlit app
|
| 202 |
+
streamlit run app.py
|
| 203 |
+
|
| 204 |
+
# Or use the CLI with Ollama (local LLM)
|
| 205 |
+
ollama serve &
|
| 206 |
+
ollama pull phi3.5
|
| 207 |
+
python -m chatbot.cli
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
### Environment Variables
|
| 211 |
+
|
| 212 |
+
| Variable | Description | Required |
|
| 213 |
+
|---|---|---|
|
| 214 |
+
| `QDRANT_URL` | Qdrant Cloud cluster URL | Yes |
|
| 215 |
+
| `QDRANT_API_KEY` | Qdrant Cloud API key | Yes |
|
| 216 |
+
| `QDRANT_COLLECTION` | Collection name (default: `uhc_policies`) | No |
|
| 217 |
+
| `GROQ_API_KEY` | Groq API key ([get free](https://console.groq.com/keys)) | Yes (web) |
|
| 218 |
+
| `GROQ_MODEL` | Groq model (default: `llama-3.1-8b-instant`) | No |
|
| 219 |
+
|
| 220 |
+
---
|
| 221 |
+
|
| 222 |
+
## Tech Stack
|
| 223 |
+
|
| 224 |
+
| Component | Technology |
|
| 225 |
+
|---|---|
|
| 226 |
+
| Embedding Model | [MedEmbed-large-v0.1](https://huggingface.co/abhinand/MedEmbed-large-v0.1) (1024-dim) |
|
| 227 |
+
| Vector Database | [Qdrant Cloud](https://qdrant.tech/) |
|
| 228 |
+
| LLM (deployed) | [Llama 3.1 8B](https://console.groq.com/) via Groq (560 tok/s) |
|
| 229 |
+
| LLM (local dev) | Phi-3.5 Mini via Ollama |
|
| 230 |
+
| Web Framework | Streamlit |
|
| 231 |
+
| Hosting | HuggingFace Spaces (free tier) |
|
| 232 |
+
| PDF Extraction | pdfplumber + BeautifulSoup |
|
app.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UHC Medical Policy Chatbot — Streamlit Web UI.
|
| 3 |
+
|
| 4 |
+
Deployed on HuggingFace Spaces. Uses MedEmbed for retrieval from Qdrant Cloud
|
| 5 |
+
and Groq (Llama 3.1 8B) for answer generation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
import streamlit as st
|
| 10 |
+
|
| 11 |
+
from chatbot.config import GROQ_MODEL, RETRIEVAL_TOP_K, MAX_HISTORY_TURNS
|
| 12 |
+
from chatbot.retriever import PolicyRetriever
|
| 13 |
+
from chatbot.llm_groq import GroqClient, GroqError
|
| 14 |
+
from chatbot.prompts import format_context, build_messages, deduplicate_chunks
|
| 15 |
+
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
# Page config
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
st.set_page_config(
|
| 20 |
+
page_title="UHC Policy Chatbot",
|
| 21 |
+
page_icon="🏥",
|
| 22 |
+
layout="centered",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Cached singletons — loaded once, shared across reruns
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
@st.cache_resource(show_spinner=False)
|
| 30 |
+
def load_retriever() -> PolicyRetriever:
|
| 31 |
+
logs: list[str] = []
|
| 32 |
+
r = PolicyRetriever()
|
| 33 |
+
r.init(status_callback=lambda msg: logs.append(msg))
|
| 34 |
+
return r
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@st.cache_resource
|
| 38 |
+
def load_llm() -> GroqClient:
|
| 39 |
+
return GroqClient()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Sidebar
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
with st.sidebar:
|
| 46 |
+
st.markdown("## 🏥 UHC Policy Chatbot")
|
| 47 |
+
st.caption(f"LLM: **{GROQ_MODEL}** via Groq")
|
| 48 |
+
st.caption(f"Retrieval: **MedEmbed** → Qdrant (top-{RETRIEVAL_TOP_K})")
|
| 49 |
+
st.divider()
|
| 50 |
+
|
| 51 |
+
st.markdown("### How to use")
|
| 52 |
+
st.markdown(
|
| 53 |
+
"Ask questions about UnitedHealthcare medical policies — "
|
| 54 |
+
"coverage criteria, CPT codes, medical necessity, and more."
|
| 55 |
+
)
|
| 56 |
+
st.markdown(
|
| 57 |
+
"**Examples:**\n"
|
| 58 |
+
"- Is bariatric surgery covered for BMI over 40?\n"
|
| 59 |
+
"- What documentation is needed for gender-affirming surgery?\n"
|
| 60 |
+
"- Is HFCWO covered for cystic fibrosis?\n"
|
| 61 |
+
"- What are the criteria for whole genome sequencing?"
|
| 62 |
+
)
|
| 63 |
+
st.divider()
|
| 64 |
+
|
| 65 |
+
if st.button("🗑️ Clear conversation"):
|
| 66 |
+
st.session_state.messages = []
|
| 67 |
+
st.session_state.chunks_history = []
|
| 68 |
+
st.rerun()
|
| 69 |
+
|
| 70 |
+
st.caption(
|
| 71 |
+
"Built for the CombineHealth Technical Assignment. "
|
| 72 |
+
"Answers are generated from policy documents only."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Init session state
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
if "messages" not in st.session_state:
|
| 79 |
+
st.session_state.messages = []
|
| 80 |
+
if "chunks_history" not in st.session_state:
|
| 81 |
+
st.session_state.chunks_history = []
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Load models
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
with st.spinner("Loading MedEmbed model and connecting to Qdrant..."):
|
| 87 |
+
retriever = load_retriever()
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
llm = load_llm()
|
| 91 |
+
except GroqError as e:
|
| 92 |
+
st.error(f"LLM initialization failed: {e}")
|
| 93 |
+
st.stop()
|
| 94 |
+
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
# Header
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
st.title("🏥 UHC Medical Policy Chatbot")
|
| 99 |
+
st.caption(
|
| 100 |
+
"Ask questions about UnitedHealthcare insurance policies. "
|
| 101 |
+
"Answers are grounded in official policy documents with source citations."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
# Render chat history
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
for i, msg in enumerate(st.session_state.messages):
|
| 108 |
+
with st.chat_message(msg["role"]):
|
| 109 |
+
st.markdown(msg["content"])
|
| 110 |
+
|
| 111 |
+
if msg["role"] == "assistant" and i // 2 < len(st.session_state.chunks_history):
|
| 112 |
+
chunks_for_msg = st.session_state.chunks_history[i // 2]
|
| 113 |
+
if chunks_for_msg:
|
| 114 |
+
with st.expander("📚 Sources", expanded=False):
|
| 115 |
+
for c in chunks_for_msg:
|
| 116 |
+
st.markdown(
|
| 117 |
+
f"- **[{c.score:.2f}]** `{c.policy_name}` — "
|
| 118 |
+
f"{c.section} *(pages {c.page_start}–{c.page_end})*"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
# Chat input
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
if query := st.chat_input("Ask about UHC medical policies..."):
|
| 125 |
+
query = query.strip()
|
| 126 |
+
|
| 127 |
+
if not query:
|
| 128 |
+
st.warning("Please enter a question.")
|
| 129 |
+
st.stop()
|
| 130 |
+
|
| 131 |
+
st.session_state.messages.append({"role": "user", "content": query})
|
| 132 |
+
with st.chat_message("user"):
|
| 133 |
+
st.markdown(query)
|
| 134 |
+
|
| 135 |
+
# -- Retrieve -------------------------------------------------------------
|
| 136 |
+
with st.chat_message("assistant"):
|
| 137 |
+
with st.spinner("Searching policies..."):
|
| 138 |
+
t0 = time.perf_counter()
|
| 139 |
+
try:
|
| 140 |
+
chunks = retriever.retrieve(query, top_k=RETRIEVAL_TOP_K)
|
| 141 |
+
except RuntimeError as e:
|
| 142 |
+
st.error(f"Retrieval error: {e}")
|
| 143 |
+
st.stop()
|
| 144 |
+
t_retrieval = time.perf_counter() - t0
|
| 145 |
+
|
| 146 |
+
if not chunks:
|
| 147 |
+
response_text = (
|
| 148 |
+
"I don't have enough policy information to answer this question. "
|
| 149 |
+
"Try rephrasing or asking about a specific UHC policy topic."
|
| 150 |
+
)
|
| 151 |
+
st.markdown(response_text)
|
| 152 |
+
st.session_state.messages.append(
|
| 153 |
+
{"role": "assistant", "content": response_text}
|
| 154 |
+
)
|
| 155 |
+
st.session_state.chunks_history.append([])
|
| 156 |
+
st.stop()
|
| 157 |
+
|
| 158 |
+
context = format_context(chunks)
|
| 159 |
+
|
| 160 |
+
history_for_llm = []
|
| 161 |
+
turns = st.session_state.messages[:-1]
|
| 162 |
+
if len(turns) > MAX_HISTORY_TURNS * 2:
|
| 163 |
+
turns = turns[-(MAX_HISTORY_TURNS * 2):]
|
| 164 |
+
for m in turns:
|
| 165 |
+
history_for_llm.append({"role": m["role"], "content": m["content"]})
|
| 166 |
+
|
| 167 |
+
messages = build_messages(query, context, history=history_for_llm)
|
| 168 |
+
|
| 169 |
+
# -- Generate -----------------------------------------------------------
|
| 170 |
+
try:
|
| 171 |
+
t1 = time.perf_counter()
|
| 172 |
+
response_text = st.write_stream(llm.chat_stream(messages))
|
| 173 |
+
t_gen = time.perf_counter() - t1
|
| 174 |
+
except GroqError as e:
|
| 175 |
+
st.error(str(e))
|
| 176 |
+
st.stop()
|
| 177 |
+
|
| 178 |
+
# -- Sources -----------------------------------------------------------
|
| 179 |
+
deduped = deduplicate_chunks(chunks)
|
| 180 |
+
with st.expander("📚 Sources", expanded=False):
|
| 181 |
+
for c in deduped:
|
| 182 |
+
st.markdown(
|
| 183 |
+
f"- **[{c.score:.2f}]** `{c.policy_name}` — "
|
| 184 |
+
f"{c.section} *(pages {c.page_start}–{c.page_end})*"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
st.caption(
|
| 188 |
+
f"Retrieval: {t_retrieval:.1f}s · Generation: {t_gen:.1f}s"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
st.session_state.messages.append(
|
| 192 |
+
{"role": "assistant", "content": response_text}
|
| 193 |
+
)
|
| 194 |
+
st.session_state.chunks_history.append(deduped)
|
chatbot/__init__.py
ADDED
|
File without changes
|
chatbot/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Interactive CLI chatbot for querying UHC medical policies.
|
| 4 |
+
|
| 5 |
+
Loads the MedEmbed model once, retrieves relevant policy chunks from Qdrant,
|
| 6 |
+
and generates answers via Phi-3.5 Mini served by Ollama.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python -m chatbot.cli
|
| 10 |
+
python -m chatbot.cli --top-k 5 --model phi3.5
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
from chatbot.config import (
|
| 18 |
+
OLLAMA_MODEL,
|
| 19 |
+
RETRIEVAL_TOP_K,
|
| 20 |
+
MAX_HISTORY_TURNS,
|
| 21 |
+
)
|
| 22 |
+
from chatbot.retriever import PolicyRetriever
|
| 23 |
+
from chatbot.llm import OllamaClient, OllamaError
|
| 24 |
+
from chatbot.prompts import format_context, build_messages
|
| 25 |
+
|
| 26 |
+
# -- ANSI colors --------------------------------------------------------------
|
| 27 |
+
DIM = "\033[2m"
|
| 28 |
+
GREEN = "\033[92m"
|
| 29 |
+
CYAN = "\033[96m"
|
| 30 |
+
YELLOW = "\033[93m"
|
| 31 |
+
RED = "\033[91m"
|
| 32 |
+
BOLD = "\033[1m"
|
| 33 |
+
RESET = "\033[0m"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def print_banner():
|
| 37 |
+
print(f"""
|
| 38 |
+
{BOLD}{'=' * 64}
|
| 39 |
+
UHC Medical Policy Chatbot
|
| 40 |
+
Model: Phi-3.5 Mini via Ollama | Retrieval: MedEmbed + Qdrant
|
| 41 |
+
{'=' * 64}{RESET}
|
| 42 |
+
|
| 43 |
+
{DIM}Commands:
|
| 44 |
+
/clear — reset conversation history
|
| 45 |
+
/debug — show retrieved chunks for the last query
|
| 46 |
+
/quit — exit{RESET}
|
| 47 |
+
""")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def print_sources(chunks):
|
| 51 |
+
"""Print a compact list of sources used."""
|
| 52 |
+
if not chunks:
|
| 53 |
+
return
|
| 54 |
+
seen = set()
|
| 55 |
+
print(f"\n{DIM}Sources:", end="")
|
| 56 |
+
for c in chunks:
|
| 57 |
+
key = f"{c.policy_name}/{c.section}"
|
| 58 |
+
if key not in seen:
|
| 59 |
+
seen.add(key)
|
| 60 |
+
print(f"\n [{c.score:.2f}] {c.policy_name} — {c.section}", end="")
|
| 61 |
+
print(RESET)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def print_debug(chunks):
|
| 65 |
+
"""Print full debug info for retrieved chunks."""
|
| 66 |
+
print(f"\n{YELLOW}{'─' * 64}")
|
| 67 |
+
print(f" DEBUG: {len(chunks)} chunks retrieved")
|
| 68 |
+
print(f"{'─' * 64}{RESET}")
|
| 69 |
+
for i, c in enumerate(chunks, 1):
|
| 70 |
+
print(f"\n{YELLOW} [{i}] score={c.score:.4f} {c.policy_name} / {c.section}{RESET}")
|
| 71 |
+
print(f"{DIM} Plan: {c.plan_type} Pages: {c.page_start}-{c.page_end}")
|
| 72 |
+
preview = c.text[:300].replace("\n", " ")
|
| 73 |
+
print(f" {preview}...{RESET}")
|
| 74 |
+
print()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
parser = argparse.ArgumentParser(description="UHC Policy Chatbot CLI")
|
| 79 |
+
parser.add_argument("--top-k", type=int, default=RETRIEVAL_TOP_K)
|
| 80 |
+
parser.add_argument("--model", type=str, default=OLLAMA_MODEL)
|
| 81 |
+
args = parser.parse_args()
|
| 82 |
+
|
| 83 |
+
print_banner()
|
| 84 |
+
|
| 85 |
+
# -- Check Ollama ---------------------------------------------------------
|
| 86 |
+
llm = OllamaClient(model=args.model)
|
| 87 |
+
err = llm.check_ready()
|
| 88 |
+
if err:
|
| 89 |
+
print(f"{RED}ERROR: {err}{RESET}")
|
| 90 |
+
sys.exit(1)
|
| 91 |
+
print(f"{DIM}Ollama ready ({args.model}){RESET}")
|
| 92 |
+
|
| 93 |
+
# -- Init retriever -------------------------------------------------------
|
| 94 |
+
retriever = PolicyRetriever()
|
| 95 |
+
retriever.init(status_callback=lambda msg: print(f"{DIM}{msg}{RESET}"))
|
| 96 |
+
print()
|
| 97 |
+
|
| 98 |
+
# -- REPL -----------------------------------------------------------------
|
| 99 |
+
history: list[dict] = []
|
| 100 |
+
last_chunks = []
|
| 101 |
+
debug_mode = False
|
| 102 |
+
|
| 103 |
+
while True:
|
| 104 |
+
try:
|
| 105 |
+
query = input(f"{CYAN}{BOLD}> {RESET}").strip()
|
| 106 |
+
except (KeyboardInterrupt, EOFError):
|
| 107 |
+
print(f"\n{DIM}Goodbye.{RESET}")
|
| 108 |
+
break
|
| 109 |
+
|
| 110 |
+
if not query:
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
# -- Commands ---------------------------------------------------------
|
| 114 |
+
if query.lower() == "/quit":
|
| 115 |
+
print(f"{DIM}Goodbye.{RESET}")
|
| 116 |
+
break
|
| 117 |
+
if query.lower() == "/clear":
|
| 118 |
+
history.clear()
|
| 119 |
+
last_chunks.clear()
|
| 120 |
+
print(f"{DIM}History cleared.{RESET}\n")
|
| 121 |
+
continue
|
| 122 |
+
if query.lower() == "/debug":
|
| 123 |
+
if last_chunks:
|
| 124 |
+
print_debug(last_chunks)
|
| 125 |
+
else:
|
| 126 |
+
print(f"{DIM}No chunks retrieved yet.{RESET}\n")
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
# -- Retrieve ---------------------------------------------------------
|
| 130 |
+
t_start = time.perf_counter()
|
| 131 |
+
try:
|
| 132 |
+
chunks = retriever.retrieve(query, top_k=args.top_k)
|
| 133 |
+
except RuntimeError as e:
|
| 134 |
+
print(f"{RED}Retrieval error: {e}{RESET}\n")
|
| 135 |
+
continue
|
| 136 |
+
t_retrieval = time.perf_counter()
|
| 137 |
+
|
| 138 |
+
last_chunks = chunks
|
| 139 |
+
context = format_context(chunks)
|
| 140 |
+
|
| 141 |
+
# -- Build messages and stream ----------------------------------------
|
| 142 |
+
messages = build_messages(query, context, history=history)
|
| 143 |
+
|
| 144 |
+
print(f"\n{GREEN}", end="", flush=True)
|
| 145 |
+
full_response = []
|
| 146 |
+
token_count = 0
|
| 147 |
+
t_first_token = None
|
| 148 |
+
try:
|
| 149 |
+
for token in llm.chat_stream(messages):
|
| 150 |
+
if t_first_token is None:
|
| 151 |
+
t_first_token = time.perf_counter()
|
| 152 |
+
print(token, end="", flush=True)
|
| 153 |
+
full_response.append(token)
|
| 154 |
+
token_count += 1
|
| 155 |
+
except OllamaError as e:
|
| 156 |
+
print(f"{RESET}\n{RED}LLM error: {e}{RESET}\n")
|
| 157 |
+
continue
|
| 158 |
+
t_done = time.perf_counter()
|
| 159 |
+
|
| 160 |
+
print(RESET)
|
| 161 |
+
|
| 162 |
+
# -- Sources ----------------------------------------------------------
|
| 163 |
+
print_sources(chunks)
|
| 164 |
+
|
| 165 |
+
# -- Latency ----------------------------------------------------------
|
| 166 |
+
retrieval_ms = (t_retrieval - t_start) * 1000
|
| 167 |
+
first_tok_ms = ((t_first_token or t_done) - t_retrieval) * 1000
|
| 168 |
+
gen_ms = (t_done - (t_first_token or t_retrieval)) * 1000
|
| 169 |
+
total_ms = (t_done - t_start) * 1000
|
| 170 |
+
tok_per_s = token_count / (gen_ms / 1000) if gen_ms > 0 else 0
|
| 171 |
+
|
| 172 |
+
print(f"\n{DIM}{'─' * 48}")
|
| 173 |
+
print(f" Retrieval: {retrieval_ms:7.0f} ms")
|
| 174 |
+
print(f" First token: {first_tok_ms:7.0f} ms")
|
| 175 |
+
print(f" Generation: {gen_ms:7.0f} ms ({token_count} tok, {tok_per_s:.1f} tok/s)")
|
| 176 |
+
print(f" Total: {total_ms:7.0f} ms")
|
| 177 |
+
print(f"{'─' * 48}{RESET}")
|
| 178 |
+
print()
|
| 179 |
+
|
| 180 |
+
# -- Update history ---------------------------------------------------
|
| 181 |
+
history.append({"role": "user", "content": query})
|
| 182 |
+
history.append({"role": "assistant", "content": "".join(full_response)})
|
| 183 |
+
|
| 184 |
+
if len(history) > MAX_HISTORY_TURNS * 2:
|
| 185 |
+
history = history[-(MAX_HISTORY_TURNS * 2):]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
if __name__ == "__main__":
|
| 189 |
+
main()
|
chatbot/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 6 |
+
load_dotenv(PROJECT_ROOT / ".env")
|
| 7 |
+
|
| 8 |
+
# --- Ollama LLM (local dev) ---
|
| 9 |
+
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 10 |
+
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "phi3.5")
|
| 11 |
+
|
| 12 |
+
# --- Groq LLM (deployed / default) ---
|
| 13 |
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
|
| 14 |
+
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
|
| 15 |
+
|
| 16 |
+
# --- LLM shared params ---
|
| 17 |
+
LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.2"))
|
| 18 |
+
LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "400"))
|
| 19 |
+
LLM_TOP_P = float(os.getenv("LLM_TOP_P", "0.9"))
|
| 20 |
+
|
| 21 |
+
# --- Retrieval ---
|
| 22 |
+
RETRIEVAL_TOP_K = int(os.getenv("RETRIEVAL_TOP_K", "6"))
|
| 23 |
+
MAX_CONTEXT_CHARS = int(os.getenv("MAX_CONTEXT_CHARS", "6000"))
|
| 24 |
+
MAX_CHUNK_CHARS = int(os.getenv("MAX_CHUNK_CHARS", "800"))
|
| 25 |
+
EXCLUDED_SECTIONS = {"References", "Application"}
|
| 26 |
+
|
| 27 |
+
# --- Conversation ---
|
| 28 |
+
MAX_HISTORY_TURNS = 3
|
| 29 |
+
|
| 30 |
+
# --- Embedding (re-export from embedding pipeline) ---
|
| 31 |
+
EMBEDDING_SCRIPTS_DIR = PROJECT_ROOT / "embedding" / "scripts"
|
chatbot/llm.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ollama HTTP client for Phi-3.5 Mini.
|
| 3 |
+
|
| 4 |
+
Streams responses token-by-token and provides health/model availability checks.
|
| 5 |
+
Uses raw requests — no extra pip packages needed beyond `requests`.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from typing import Generator
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from chatbot.config import (
|
| 14 |
+
OLLAMA_BASE_URL,
|
| 15 |
+
OLLAMA_MODEL,
|
| 16 |
+
LLM_TEMPERATURE,
|
| 17 |
+
LLM_MAX_TOKENS,
|
| 18 |
+
LLM_TOP_P,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class OllamaError(Exception):
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class OllamaClient:
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
base_url: str = OLLAMA_BASE_URL,
|
| 30 |
+
model: str = OLLAMA_MODEL,
|
| 31 |
+
):
|
| 32 |
+
self.base_url = base_url.rstrip("/")
|
| 33 |
+
self.model = model
|
| 34 |
+
|
| 35 |
+
# -- Health checks --------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
def is_running(self) -> bool:
|
| 38 |
+
try:
|
| 39 |
+
r = requests.get(f"{self.base_url}/api/tags", timeout=5)
|
| 40 |
+
return r.status_code == 200
|
| 41 |
+
except requests.ConnectionError:
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
def is_model_available(self) -> bool:
|
| 45 |
+
try:
|
| 46 |
+
r = requests.get(f"{self.base_url}/api/tags", timeout=5)
|
| 47 |
+
if r.status_code != 200:
|
| 48 |
+
return False
|
| 49 |
+
models = r.json().get("models", [])
|
| 50 |
+
return any(
|
| 51 |
+
m.get("name", "").startswith(self.model)
|
| 52 |
+
for m in models
|
| 53 |
+
)
|
| 54 |
+
except (requests.ConnectionError, ValueError):
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
def check_ready(self) -> str | None:
|
| 58 |
+
"""Return an error message if not ready, else None."""
|
| 59 |
+
if not self.is_running():
|
| 60 |
+
return (
|
| 61 |
+
"Ollama is not running.\n"
|
| 62 |
+
" Start it with: ollama serve\n"
|
| 63 |
+
" Or install: brew install ollama"
|
| 64 |
+
)
|
| 65 |
+
if not self.is_model_available():
|
| 66 |
+
return (
|
| 67 |
+
f"Model '{self.model}' is not pulled.\n"
|
| 68 |
+
f" Pull it with: ollama pull {self.model}"
|
| 69 |
+
)
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
# -- Chat -----------------------------------------------------------------
|
| 73 |
+
|
| 74 |
+
def chat_stream(
|
| 75 |
+
self,
|
| 76 |
+
messages: list[dict],
|
| 77 |
+
temperature: float = LLM_TEMPERATURE,
|
| 78 |
+
max_tokens: int = LLM_MAX_TOKENS,
|
| 79 |
+
top_p: float = LLM_TOP_P,
|
| 80 |
+
) -> Generator[str, None, None]:
|
| 81 |
+
"""
|
| 82 |
+
Send a chat completion request and yield tokens as they arrive.
|
| 83 |
+
`messages` follows the OpenAI-style format:
|
| 84 |
+
[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
|
| 85 |
+
"""
|
| 86 |
+
payload = {
|
| 87 |
+
"model": self.model,
|
| 88 |
+
"messages": messages,
|
| 89 |
+
"stream": True,
|
| 90 |
+
"options": {
|
| 91 |
+
"temperature": temperature,
|
| 92 |
+
"num_predict": max_tokens,
|
| 93 |
+
"top_p": top_p,
|
| 94 |
+
},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
resp = requests.post(
|
| 99 |
+
f"{self.base_url}/api/chat",
|
| 100 |
+
json=payload,
|
| 101 |
+
stream=True,
|
| 102 |
+
timeout=120,
|
| 103 |
+
)
|
| 104 |
+
resp.raise_for_status()
|
| 105 |
+
except requests.ConnectionError:
|
| 106 |
+
raise OllamaError(
|
| 107 |
+
"Cannot reach Ollama. Is it running? (ollama serve)"
|
| 108 |
+
)
|
| 109 |
+
except requests.HTTPError as e:
|
| 110 |
+
raise OllamaError(f"Ollama returned an error: {e}")
|
| 111 |
+
|
| 112 |
+
for line in resp.iter_lines(decode_unicode=True):
|
| 113 |
+
if not line:
|
| 114 |
+
continue
|
| 115 |
+
try:
|
| 116 |
+
chunk = json.loads(line)
|
| 117 |
+
except json.JSONDecodeError:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
token = chunk.get("message", {}).get("content", "")
|
| 121 |
+
if token:
|
| 122 |
+
yield token
|
| 123 |
+
|
| 124 |
+
if chunk.get("done", False):
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
def chat(self, messages: list[dict], **kwargs) -> str:
|
| 128 |
+
"""Non-streaming convenience wrapper."""
|
| 129 |
+
return "".join(self.chat_stream(messages, **kwargs))
|
chatbot/llm_groq.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Groq API client for LLM chat completions.
|
| 3 |
+
|
| 4 |
+
Uses the Groq SDK with streaming support. Drop-in replacement for OllamaClient
|
| 5 |
+
when deploying to environments without a local Ollama server.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Generator
|
| 9 |
+
|
| 10 |
+
from groq import Groq
|
| 11 |
+
|
| 12 |
+
from chatbot.config import (
|
| 13 |
+
GROQ_API_KEY,
|
| 14 |
+
GROQ_MODEL,
|
| 15 |
+
LLM_TEMPERATURE,
|
| 16 |
+
LLM_MAX_TOKENS,
|
| 17 |
+
LLM_TOP_P,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class GroqError(Exception):
|
| 22 |
+
pass
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class GroqClient:
|
| 26 |
+
def __init__(self, api_key: str = GROQ_API_KEY, model: str = GROQ_MODEL):
|
| 27 |
+
if not api_key:
|
| 28 |
+
raise GroqError(
|
| 29 |
+
"GROQ_API_KEY is not set. Get a free key at https://console.groq.com/keys"
|
| 30 |
+
)
|
| 31 |
+
self._client = Groq(api_key=api_key)
|
| 32 |
+
self.model = model
|
| 33 |
+
|
| 34 |
+
def check_ready(self) -> str | None:
|
| 35 |
+
"""Return an error message if not ready, else None."""
|
| 36 |
+
try:
|
| 37 |
+
self._client.models.list()
|
| 38 |
+
return None
|
| 39 |
+
except Exception as e:
|
| 40 |
+
return f"Groq API error: {e}"
|
| 41 |
+
|
| 42 |
+
def chat_stream(
|
| 43 |
+
self,
|
| 44 |
+
messages: list[dict],
|
| 45 |
+
temperature: float = LLM_TEMPERATURE,
|
| 46 |
+
max_tokens: int = LLM_MAX_TOKENS,
|
| 47 |
+
top_p: float = LLM_TOP_P,
|
| 48 |
+
) -> Generator[str, None, None]:
|
| 49 |
+
try:
|
| 50 |
+
stream = self._client.chat.completions.create(
|
| 51 |
+
model=self.model,
|
| 52 |
+
messages=messages,
|
| 53 |
+
temperature=temperature,
|
| 54 |
+
max_completion_tokens=max_tokens,
|
| 55 |
+
top_p=top_p,
|
| 56 |
+
stream=True,
|
| 57 |
+
)
|
| 58 |
+
for chunk in stream:
|
| 59 |
+
delta = chunk.choices[0].delta
|
| 60 |
+
if delta and delta.content:
|
| 61 |
+
yield delta.content
|
| 62 |
+
except Exception as e:
|
| 63 |
+
error_msg = str(e).lower()
|
| 64 |
+
if "rate_limit" in error_msg or "429" in error_msg:
|
| 65 |
+
raise GroqError(
|
| 66 |
+
"Groq rate limit reached. Please wait a moment and try again."
|
| 67 |
+
)
|
| 68 |
+
raise GroqError(f"Groq API error: {e}")
|
| 69 |
+
|
| 70 |
+
def chat(self, messages: list[dict], **kwargs) -> str:
|
| 71 |
+
"""Non-streaming convenience wrapper."""
|
| 72 |
+
return "".join(self.chat_stream(messages, **kwargs))
|
chatbot/prompts.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System prompt, context formatting, and chunk deduplication for the RAG chatbot.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from chatbot.config import MAX_CONTEXT_CHARS, MAX_CHUNK_CHARS
|
| 6 |
+
from chatbot.retriever import ChunkResult
|
| 7 |
+
|
| 8 |
+
SYSTEM_PROMPT = """\
|
| 9 |
+
You are a UHC medical policy assistant. Users are doctors and hospital staff.
|
| 10 |
+
|
| 11 |
+
RULES:
|
| 12 |
+
1. Answer ONLY from the policy excerpts below. No outside knowledge.
|
| 13 |
+
2. Be BRIEF: 2-4 bullet points max. One short paragraph for summary if needed.
|
| 14 |
+
3. Cite sources as (policy-name, Section). Example: (bariatric-surgery, Coverage Rationale).
|
| 15 |
+
4. If context lacks the answer, say "I don't have enough policy information to answer this."
|
| 16 |
+
5. If something is "unproven and not medically necessary," say it is NOT covered.
|
| 17 |
+
6. Do NOT repeat or paraphrase the same point multiple times. State each fact once.
|
| 18 |
+
7. Coverage Rationale is the authoritative source for what IS and IS NOT covered. \
|
| 19 |
+
If Coverage Rationale says a treatment is unproven/not medically necessary for a condition, \
|
| 20 |
+
clearly state it is NOT covered — even if Clinical Evidence discusses studies about it."""
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def deduplicate_chunks(chunks: list[ChunkResult]) -> list[ChunkResult]:
|
| 24 |
+
"""Keep the highest-scoring chunk per (policy_name, section) pair."""
|
| 25 |
+
seen: dict[tuple[str, str], ChunkResult] = {}
|
| 26 |
+
for c in chunks:
|
| 27 |
+
key = (c.policy_name, c.section)
|
| 28 |
+
if key not in seen or c.score > seen[key].score:
|
| 29 |
+
seen[key] = c
|
| 30 |
+
return sorted(seen.values(), key=lambda c: c.score, reverse=True)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _truncate_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> str:
|
| 34 |
+
"""Truncate chunk text to max_chars, breaking at sentence boundary."""
|
| 35 |
+
if len(text) <= max_chars:
|
| 36 |
+
return text
|
| 37 |
+
cut = text[:max_chars]
|
| 38 |
+
last_period = cut.rfind(". ")
|
| 39 |
+
if last_period > max_chars // 2:
|
| 40 |
+
return cut[:last_period + 1]
|
| 41 |
+
return cut + "..."
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def format_context(chunks: list[ChunkResult]) -> str:
|
| 45 |
+
"""
|
| 46 |
+
Render retrieved chunks into a numbered context block for the LLM.
|
| 47 |
+
Each chunk is truncated to MAX_CHUNK_CHARS, total capped at MAX_CONTEXT_CHARS.
|
| 48 |
+
"""
|
| 49 |
+
deduped = deduplicate_chunks(chunks)
|
| 50 |
+
|
| 51 |
+
parts = []
|
| 52 |
+
char_count = 0
|
| 53 |
+
|
| 54 |
+
for i, c in enumerate(deduped, 1):
|
| 55 |
+
header = f"[{i}] {c.policy_name} | {c.section} | {c.plan_type}"
|
| 56 |
+
body = _truncate_text(c.text)
|
| 57 |
+
block = f"{header}\n{body}\n"
|
| 58 |
+
|
| 59 |
+
if char_count + len(block) > MAX_CONTEXT_CHARS:
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
parts.append(block)
|
| 63 |
+
char_count += len(block)
|
| 64 |
+
|
| 65 |
+
return "\n---\n".join(parts)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def build_messages(
|
| 69 |
+
query: str,
|
| 70 |
+
context: str,
|
| 71 |
+
history: list[dict] | None = None,
|
| 72 |
+
) -> list[dict]:
|
| 73 |
+
"""
|
| 74 |
+
Build the Ollama chat messages list.
|
| 75 |
+
`history` is a list of prior {"role": ..., "content": ...} entries.
|
| 76 |
+
"""
|
| 77 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 78 |
+
|
| 79 |
+
if history:
|
| 80 |
+
messages.extend(history)
|
| 81 |
+
|
| 82 |
+
user_content = (
|
| 83 |
+
f"CONTEXT:\n{context}\n\n"
|
| 84 |
+
f"QUESTION: {query}\n\n"
|
| 85 |
+
f"Answer briefly in 2-4 bullet points with citations."
|
| 86 |
+
)
|
| 87 |
+
messages.append({"role": "user", "content": user_content})
|
| 88 |
+
|
| 89 |
+
return messages
|
chatbot/retriever.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Retriever wrapping the embedding pipeline.
|
| 3 |
+
|
| 4 |
+
Loads MedEmbed + Qdrant client once and exposes a simple `retrieve(query)` interface.
|
| 5 |
+
Filters out low-value sections (References, Application) that pollute results.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from sentence_transformers import SentenceTransformer
|
| 14 |
+
from qdrant_client import QdrantClient
|
| 15 |
+
from qdrant_client.models import (
|
| 16 |
+
Filter,
|
| 17 |
+
FieldCondition,
|
| 18 |
+
MatchValue,
|
| 19 |
+
MatchExcept,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
from chatbot.config import (
|
| 23 |
+
EMBEDDING_SCRIPTS_DIR,
|
| 24 |
+
RETRIEVAL_TOP_K,
|
| 25 |
+
EXCLUDED_SECTIONS,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, str(EMBEDDING_SCRIPTS_DIR))
|
| 29 |
+
from config import (
|
| 30 |
+
EMBEDDING_MODEL_NAME,
|
| 31 |
+
MAX_SEQ_LENGTH,
|
| 32 |
+
QDRANT_URL,
|
| 33 |
+
QDRANT_API_KEY,
|
| 34 |
+
QDRANT_HOST,
|
| 35 |
+
QDRANT_PORT,
|
| 36 |
+
QDRANT_COLLECTION,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
MAX_RETRIES = 3
|
| 40 |
+
RETRY_BACKOFF = 2
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class ChunkResult:
|
| 45 |
+
text: str
|
| 46 |
+
policy_name: str
|
| 47 |
+
section: str
|
| 48 |
+
plan_type: str
|
| 49 |
+
score: float
|
| 50 |
+
page_start: int = 0
|
| 51 |
+
page_end: int = 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class PolicyRetriever:
|
| 55 |
+
"""Loads models once, reuses across queries."""
|
| 56 |
+
|
| 57 |
+
def __init__(self):
|
| 58 |
+
self._model = None
|
| 59 |
+
self._device = None
|
| 60 |
+
self._client = None
|
| 61 |
+
|
| 62 |
+
def _ensure_model(self):
|
| 63 |
+
if self._model is not None:
|
| 64 |
+
return
|
| 65 |
+
self._device = (
|
| 66 |
+
"cuda" if torch.cuda.is_available()
|
| 67 |
+
else "mps" if torch.backends.mps.is_available()
|
| 68 |
+
else "cpu"
|
| 69 |
+
)
|
| 70 |
+
self._model = SentenceTransformer(EMBEDDING_MODEL_NAME, trust_remote_code=False)
|
| 71 |
+
self._model.max_seq_length = MAX_SEQ_LENGTH
|
| 72 |
+
|
| 73 |
+
def _ensure_client(self):
|
| 74 |
+
if self._client is not None:
|
| 75 |
+
return
|
| 76 |
+
if QDRANT_URL:
|
| 77 |
+
self._client = QdrantClient(
|
| 78 |
+
url=QDRANT_URL, api_key=QDRANT_API_KEY,
|
| 79 |
+
timeout=30, prefer_grpc=False,
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
self._client = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT, timeout=30)
|
| 83 |
+
|
| 84 |
+
def init(self, status_callback=None):
|
| 85 |
+
"""Eagerly load model + client. Optional callback for progress messages."""
|
| 86 |
+
cb = status_callback or (lambda msg: None)
|
| 87 |
+
|
| 88 |
+
cb("Loading MedEmbed model...")
|
| 89 |
+
self._ensure_model()
|
| 90 |
+
cb(f" Model loaded on {self._device}")
|
| 91 |
+
|
| 92 |
+
cb("Connecting to Qdrant...")
|
| 93 |
+
self._ensure_client()
|
| 94 |
+
cb(" Connected")
|
| 95 |
+
return self
|
| 96 |
+
|
| 97 |
+
def retrieve(
|
| 98 |
+
self,
|
| 99 |
+
query: str,
|
| 100 |
+
top_k: int = RETRIEVAL_TOP_K,
|
| 101 |
+
section_filter: str | None = None,
|
| 102 |
+
policy_filter: str | None = None,
|
| 103 |
+
exclude_sections: bool = True,
|
| 104 |
+
) -> list[ChunkResult]:
|
| 105 |
+
self._ensure_model()
|
| 106 |
+
self._ensure_client()
|
| 107 |
+
|
| 108 |
+
vec = self._model.encode(
|
| 109 |
+
query,
|
| 110 |
+
convert_to_numpy=True,
|
| 111 |
+
normalize_embeddings=True,
|
| 112 |
+
device=self._device,
|
| 113 |
+
).tolist()
|
| 114 |
+
|
| 115 |
+
conditions = []
|
| 116 |
+
if exclude_sections and EXCLUDED_SECTIONS:
|
| 117 |
+
conditions.append(
|
| 118 |
+
FieldCondition(
|
| 119 |
+
key="section",
|
| 120 |
+
match=MatchExcept(**{"except": list(EXCLUDED_SECTIONS)}),
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
if section_filter:
|
| 124 |
+
conditions.append(FieldCondition(key="section", match=MatchValue(value=section_filter)))
|
| 125 |
+
if policy_filter:
|
| 126 |
+
conditions.append(FieldCondition(key="policy_name", match=MatchValue(value=policy_filter)))
|
| 127 |
+
|
| 128 |
+
qf = Filter(must=conditions) if conditions else None
|
| 129 |
+
|
| 130 |
+
for attempt in range(1, MAX_RETRIES + 1):
|
| 131 |
+
try:
|
| 132 |
+
hits = self._client.query_points(
|
| 133 |
+
collection_name=QDRANT_COLLECTION,
|
| 134 |
+
query=vec,
|
| 135 |
+
query_filter=qf,
|
| 136 |
+
limit=top_k,
|
| 137 |
+
with_payload=True,
|
| 138 |
+
).points
|
| 139 |
+
break
|
| 140 |
+
except Exception as e:
|
| 141 |
+
if attempt < MAX_RETRIES:
|
| 142 |
+
time.sleep(RETRY_BACKOFF ** attempt)
|
| 143 |
+
else:
|
| 144 |
+
raise RuntimeError(
|
| 145 |
+
f"Qdrant query failed after {MAX_RETRIES} retries: {e}"
|
| 146 |
+
) from e
|
| 147 |
+
|
| 148 |
+
results = []
|
| 149 |
+
for hit in hits:
|
| 150 |
+
p = hit.payload
|
| 151 |
+
results.append(ChunkResult(
|
| 152 |
+
text=p.get("text", ""),
|
| 153 |
+
policy_name=p.get("policy_name", ""),
|
| 154 |
+
section=p.get("section", ""),
|
| 155 |
+
plan_type=p.get("plan_type", ""),
|
| 156 |
+
score=hit.score,
|
| 157 |
+
page_start=p.get("page_start", 0),
|
| 158 |
+
page_end=p.get("page_end", 0),
|
| 159 |
+
))
|
| 160 |
+
|
| 161 |
+
# Boost Coverage Rationale so it always survives deduplication.
|
| 162 |
+
# This ensures the authoritative coverage/non-coverage statement
|
| 163 |
+
# is present even when Clinical Evidence chunks score higher.
|
| 164 |
+
COVERAGE_BOOST = 0.02
|
| 165 |
+
for r in results:
|
| 166 |
+
if r.section == "Coverage Rationale":
|
| 167 |
+
r.score += COVERAGE_BOOST
|
| 168 |
+
|
| 169 |
+
return results
|
embedding/scripts/__init__.py
ADDED
|
File without changes
|
embedding/scripts/config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
+
PROJECT_ROOT = BASE_DIR.parent
|
| 9 |
+
|
| 10 |
+
# --- Paths ---
|
| 11 |
+
RAG_CHUNKS_PATH = PROJECT_ROOT / "scraper" / "data" / "processed" / "rag_chunks.json"
|
| 12 |
+
EMBEDDINGS_DIR = BASE_DIR / "data" / "embeddings"
|
| 13 |
+
EMBEDDINGS_FILE = EMBEDDINGS_DIR / "chunk_embeddings.npz"
|
| 14 |
+
|
| 15 |
+
# --- Embedding Model ---
|
| 16 |
+
EMBEDDING_MODEL_NAME = "abhinand/MedEmbed-large-v0.1"
|
| 17 |
+
EMBEDDING_DIM = 1024
|
| 18 |
+
BATCH_SIZE = 32
|
| 19 |
+
MAX_SEQ_LENGTH = 512
|
| 20 |
+
|
| 21 |
+
# --- Qdrant ---
|
| 22 |
+
QDRANT_URL = os.getenv("QDRANT_URL", None)
|
| 23 |
+
QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", None)
|
| 24 |
+
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
|
| 25 |
+
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
|
| 26 |
+
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "uhc_policies")
|
| 27 |
+
|
| 28 |
+
# --- Search ---
|
| 29 |
+
TOP_K = 10
|
| 30 |
+
|
| 31 |
+
# --- Provider (for multi-provider extensibility) ---
|
| 32 |
+
PROVIDER_NAME = "UnitedHealthcare"
|
| 33 |
+
PROVIDER_SLUG = "uhc"
|
embedding/scripts/embed_chunks.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate embeddings for RAG chunks using MedEmbed-large-v0.1.
|
| 3 |
+
|
| 4 |
+
Reads rag_chunks.json, encodes every chunk's text with the MedEmbed model,
|
| 5 |
+
and saves the resulting vectors alongside their chunk IDs to a compressed
|
| 6 |
+
numpy archive (.npz) for downstream loading into Qdrant.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python embed_chunks.py # full run
|
| 10 |
+
python embed_chunks.py --limit 100 # embed only first 100 chunks (for testing)
|
| 11 |
+
python embed_chunks.py --batch-size 64 # override batch size
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import time
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
from sentence_transformers import SentenceTransformer
|
| 20 |
+
from tqdm import tqdm
|
| 21 |
+
|
| 22 |
+
from config import (
|
| 23 |
+
RAG_CHUNKS_PATH,
|
| 24 |
+
EMBEDDINGS_DIR,
|
| 25 |
+
EMBEDDINGS_FILE,
|
| 26 |
+
EMBEDDING_MODEL_NAME,
|
| 27 |
+
BATCH_SIZE,
|
| 28 |
+
MAX_SEQ_LENGTH,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def select_device() -> str:
|
| 33 |
+
if torch.cuda.is_available():
|
| 34 |
+
return "cuda"
|
| 35 |
+
if torch.backends.mps.is_available():
|
| 36 |
+
return "mps"
|
| 37 |
+
return "cpu"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def load_chunks(path, limit=None):
|
| 41 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 42 |
+
chunks = json.load(f)
|
| 43 |
+
if limit:
|
| 44 |
+
chunks = chunks[:limit]
|
| 45 |
+
return chunks
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def build_embedding_text(chunk: dict) -> str:
|
| 49 |
+
"""
|
| 50 |
+
Construct the text that gets embedded. Prepend key metadata so the
|
| 51 |
+
embedding captures policy context, not just the raw paragraph.
|
| 52 |
+
"""
|
| 53 |
+
parts = []
|
| 54 |
+
|
| 55 |
+
policy = chunk.get("policy_name", "").replace("-", " ").title()
|
| 56 |
+
if policy:
|
| 57 |
+
parts.append(f"Policy: {policy}")
|
| 58 |
+
|
| 59 |
+
section = chunk.get("section", "")
|
| 60 |
+
if section:
|
| 61 |
+
parts.append(f"Section: {section}")
|
| 62 |
+
|
| 63 |
+
parts.append(chunk["text"])
|
| 64 |
+
|
| 65 |
+
return " | ".join(parts)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def embed_in_batches(model, texts, batch_size, device):
|
| 69 |
+
all_embeddings = []
|
| 70 |
+
|
| 71 |
+
for i in tqdm(range(0, len(texts), batch_size), desc="Embedding batches"):
|
| 72 |
+
batch = texts[i : i + batch_size]
|
| 73 |
+
embeddings = model.encode(
|
| 74 |
+
batch,
|
| 75 |
+
batch_size=batch_size,
|
| 76 |
+
show_progress_bar=False,
|
| 77 |
+
convert_to_numpy=True,
|
| 78 |
+
normalize_embeddings=True,
|
| 79 |
+
device=device,
|
| 80 |
+
)
|
| 81 |
+
all_embeddings.append(embeddings)
|
| 82 |
+
|
| 83 |
+
return np.vstack(all_embeddings)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def main():
|
| 87 |
+
parser = argparse.ArgumentParser(description="Embed RAG chunks with MedEmbed")
|
| 88 |
+
parser.add_argument("--limit", type=int, default=None, help="Limit chunks to embed (for testing)")
|
| 89 |
+
parser.add_argument("--batch-size", type=int, default=BATCH_SIZE, help="Batch size for encoding")
|
| 90 |
+
args = parser.parse_args()
|
| 91 |
+
|
| 92 |
+
device = select_device()
|
| 93 |
+
print(f"Device: {device}")
|
| 94 |
+
print(f"Model: {EMBEDDING_MODEL_NAME}")
|
| 95 |
+
print(f"Batch: {args.batch_size}")
|
| 96 |
+
|
| 97 |
+
print("\nLoading chunks...")
|
| 98 |
+
chunks = load_chunks(RAG_CHUNKS_PATH, limit=args.limit)
|
| 99 |
+
print(f"Loaded {len(chunks)} chunks")
|
| 100 |
+
|
| 101 |
+
chunk_ids = [c["id"] for c in chunks]
|
| 102 |
+
texts = [build_embedding_text(c) for c in chunks]
|
| 103 |
+
|
| 104 |
+
print(f"\nLoading model {EMBEDDING_MODEL_NAME}...")
|
| 105 |
+
model = SentenceTransformer(EMBEDDING_MODEL_NAME, trust_remote_code=True)
|
| 106 |
+
model.max_seq_length = MAX_SEQ_LENGTH
|
| 107 |
+
print(f"Model loaded — embedding dim: {model.get_sentence_embedding_dimension()}")
|
| 108 |
+
|
| 109 |
+
print("\nGenerating embeddings...")
|
| 110 |
+
start = time.time()
|
| 111 |
+
embeddings = embed_in_batches(model, texts, args.batch_size, device)
|
| 112 |
+
elapsed = time.time() - start
|
| 113 |
+
|
| 114 |
+
print(f"\nEmbeddings shape: {embeddings.shape}")
|
| 115 |
+
print(f"Time: {elapsed:.1f}s ({len(texts) / elapsed:.1f} chunks/sec)")
|
| 116 |
+
|
| 117 |
+
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
| 118 |
+
np.savez_compressed(
|
| 119 |
+
EMBEDDINGS_FILE,
|
| 120 |
+
ids=np.array(chunk_ids, dtype=object),
|
| 121 |
+
embeddings=embeddings,
|
| 122 |
+
)
|
| 123 |
+
size_mb = EMBEDDINGS_FILE.stat().st_size / (1024 * 1024)
|
| 124 |
+
print(f"\nSaved to {EMBEDDINGS_FILE} ({size_mb:.1f} MB)")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
main()
|
embedding/scripts/search.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test search interface against the Qdrant vector store.
|
| 3 |
+
|
| 4 |
+
Encodes a query with the same MedEmbed model and retrieves the top-K
|
| 5 |
+
most relevant policy chunks, demonstrating the full retrieval pipeline.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python search.py "Is bariatric surgery covered for BMI over 40?"
|
| 9 |
+
python search.py "What CPT codes are used for cochlear implants?"
|
| 10 |
+
python search.py "criteria for sleep apnea treatment" --top-k 5
|
| 11 |
+
python search.py "coverage for gene therapy hemophilia" --section "Coverage Rationale"
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
from sentence_transformers import SentenceTransformer
|
| 20 |
+
from qdrant_client import QdrantClient
|
| 21 |
+
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
| 22 |
+
|
| 23 |
+
from config import (
|
| 24 |
+
EMBEDDING_MODEL_NAME,
|
| 25 |
+
MAX_SEQ_LENGTH,
|
| 26 |
+
QDRANT_HOST,
|
| 27 |
+
QDRANT_PORT,
|
| 28 |
+
QDRANT_COLLECTION,
|
| 29 |
+
QDRANT_URL,
|
| 30 |
+
QDRANT_API_KEY,
|
| 31 |
+
TOP_K,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
MAX_RETRIES = 3
|
| 35 |
+
RETRY_BACKOFF = 2
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_client() -> QdrantClient:
|
| 39 |
+
if QDRANT_URL:
|
| 40 |
+
if not QDRANT_API_KEY or QDRANT_API_KEY == "YOUR_API_KEY_HERE":
|
| 41 |
+
print(
|
| 42 |
+
"WARNING: QDRANT_API_KEY is not set or still a placeholder.\n"
|
| 43 |
+
" Filtered queries WILL fail. Set a real key in .env",
|
| 44 |
+
file=sys.stderr,
|
| 45 |
+
)
|
| 46 |
+
return QdrantClient(
|
| 47 |
+
url=QDRANT_URL,
|
| 48 |
+
api_key=QDRANT_API_KEY,
|
| 49 |
+
timeout=30,
|
| 50 |
+
prefer_grpc=False,
|
| 51 |
+
)
|
| 52 |
+
return QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT, timeout=30)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_model():
|
| 56 |
+
device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
|
| 57 |
+
model = SentenceTransformer(EMBEDDING_MODEL_NAME, trust_remote_code=False)
|
| 58 |
+
model.max_seq_length = MAX_SEQ_LENGTH
|
| 59 |
+
return model, device
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def search(client, model, device, query, top_k=TOP_K, section_filter=None, policy_filter=None):
|
| 63 |
+
query_vector = model.encode(
|
| 64 |
+
query,
|
| 65 |
+
convert_to_numpy=True,
|
| 66 |
+
normalize_embeddings=True,
|
| 67 |
+
device=device,
|
| 68 |
+
).tolist()
|
| 69 |
+
|
| 70 |
+
conditions = []
|
| 71 |
+
if section_filter:
|
| 72 |
+
conditions.append(FieldCondition(key="section", match=MatchValue(value=section_filter)))
|
| 73 |
+
if policy_filter:
|
| 74 |
+
conditions.append(FieldCondition(key="policy_name", match=MatchValue(value=policy_filter)))
|
| 75 |
+
|
| 76 |
+
search_filter = Filter(must=conditions) if conditions else None
|
| 77 |
+
|
| 78 |
+
for attempt in range(1, MAX_RETRIES + 1):
|
| 79 |
+
try:
|
| 80 |
+
results = client.query_points(
|
| 81 |
+
collection_name=QDRANT_COLLECTION,
|
| 82 |
+
query=query_vector,
|
| 83 |
+
query_filter=search_filter,
|
| 84 |
+
limit=top_k,
|
| 85 |
+
with_payload=True,
|
| 86 |
+
)
|
| 87 |
+
return results.points
|
| 88 |
+
except Exception as e:
|
| 89 |
+
if attempt < MAX_RETRIES:
|
| 90 |
+
wait = RETRY_BACKOFF ** attempt
|
| 91 |
+
print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s...")
|
| 92 |
+
time.sleep(wait)
|
| 93 |
+
else:
|
| 94 |
+
raise RuntimeError(
|
| 95 |
+
f"Failed after {MAX_RETRIES} attempts. Last error: {e}\n"
|
| 96 |
+
"Check that QDRANT_URL and QDRANT_API_KEY in .env are correct."
|
| 97 |
+
) from e
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def format_result(hit, rank):
|
| 101 |
+
p = hit.payload
|
| 102 |
+
lines = [
|
| 103 |
+
f"\n{'='*80}",
|
| 104 |
+
f" Rank #{rank} | Score: {hit.score:.4f}",
|
| 105 |
+
f" Policy: {p.get('policy_name', 'N/A')}",
|
| 106 |
+
f" Section: {p.get('section', 'N/A')}",
|
| 107 |
+
f" Effective: {p.get('effective_date', 'N/A')}",
|
| 108 |
+
f" Plan: {p.get('plan_type', 'N/A')}",
|
| 109 |
+
f" Pages: {p.get('page_start', '?')}-{p.get('page_end', '?')}",
|
| 110 |
+
f"{'─'*80}",
|
| 111 |
+
]
|
| 112 |
+
text = p.get("text", "")
|
| 113 |
+
preview = text[:500] + ("..." if len(text) > 500 else "")
|
| 114 |
+
lines.append(f" {preview}")
|
| 115 |
+
lines.append(f"{'='*80}")
|
| 116 |
+
return "\n".join(lines)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main():
|
| 120 |
+
parser = argparse.ArgumentParser(description="Search UHC policy chunks")
|
| 121 |
+
parser.add_argument("query", type=str, help="Search query")
|
| 122 |
+
parser.add_argument("--top-k", type=int, default=TOP_K, help="Number of results")
|
| 123 |
+
parser.add_argument("--section", type=str, default=None, help="Filter by section name")
|
| 124 |
+
parser.add_argument("--policy", type=str, default=None, help="Filter by policy slug")
|
| 125 |
+
args = parser.parse_args()
|
| 126 |
+
|
| 127 |
+
print(f"Query: \"{args.query}\"")
|
| 128 |
+
if args.section:
|
| 129 |
+
print(f"Section filter: {args.section}")
|
| 130 |
+
if args.policy:
|
| 131 |
+
print(f"Policy filter: {args.policy}")
|
| 132 |
+
|
| 133 |
+
print("\nLoading model...")
|
| 134 |
+
model, device = load_model()
|
| 135 |
+
|
| 136 |
+
print("Connecting to Qdrant...")
|
| 137 |
+
client = get_client()
|
| 138 |
+
|
| 139 |
+
print(f"Searching (top-{args.top_k})...\n")
|
| 140 |
+
try:
|
| 141 |
+
results = search(client, model, device, args.query, args.top_k, args.section, args.policy)
|
| 142 |
+
except RuntimeError as e:
|
| 143 |
+
print(f"\nERROR: {e}", file=sys.stderr)
|
| 144 |
+
sys.exit(1)
|
| 145 |
+
|
| 146 |
+
if not results:
|
| 147 |
+
print("No results found.")
|
| 148 |
+
return
|
| 149 |
+
|
| 150 |
+
for i, hit in enumerate(results, 1):
|
| 151 |
+
print(format_result(hit, i))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
main()
|
embedding/scripts/store_qdrant.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Load precomputed embeddings + chunk metadata and upsert into Qdrant.
|
| 3 |
+
|
| 4 |
+
Supports both local Qdrant (docker) and Qdrant Cloud via env vars.
|
| 5 |
+
Creates the collection with proper HNSW config if it doesn't exist.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python store_qdrant.py # full upsert
|
| 9 |
+
python store_qdrant.py --recreate # drop + recreate collection first
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import numpy as np
|
| 15 |
+
from tqdm import tqdm
|
| 16 |
+
from qdrant_client import QdrantClient
|
| 17 |
+
from qdrant_client.models import (
|
| 18 |
+
Distance,
|
| 19 |
+
VectorParams,
|
| 20 |
+
PointStruct,
|
| 21 |
+
HnswConfigDiff,
|
| 22 |
+
OptimizersConfigDiff,
|
| 23 |
+
PayloadSchemaType,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
from config import (
|
| 27 |
+
RAG_CHUNKS_PATH,
|
| 28 |
+
EMBEDDINGS_FILE,
|
| 29 |
+
EMBEDDING_DIM,
|
| 30 |
+
QDRANT_HOST,
|
| 31 |
+
QDRANT_PORT,
|
| 32 |
+
QDRANT_COLLECTION,
|
| 33 |
+
QDRANT_URL,
|
| 34 |
+
QDRANT_API_KEY,
|
| 35 |
+
PROVIDER_NAME,
|
| 36 |
+
PROVIDER_SLUG,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
UPSERT_BATCH_SIZE = 100
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_client() -> QdrantClient:
|
| 43 |
+
if QDRANT_URL:
|
| 44 |
+
return QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=60)
|
| 45 |
+
return QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT, timeout=60)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def ensure_collection(client: QdrantClient, recreate: bool = False):
|
| 49 |
+
exists = client.collection_exists(QDRANT_COLLECTION)
|
| 50 |
+
|
| 51 |
+
if exists and recreate:
|
| 52 |
+
print(f"Dropping existing collection '{QDRANT_COLLECTION}'...")
|
| 53 |
+
client.delete_collection(QDRANT_COLLECTION)
|
| 54 |
+
exists = False
|
| 55 |
+
|
| 56 |
+
if not exists:
|
| 57 |
+
print(f"Creating collection '{QDRANT_COLLECTION}' (dim={EMBEDDING_DIM})...")
|
| 58 |
+
client.create_collection(
|
| 59 |
+
collection_name=QDRANT_COLLECTION,
|
| 60 |
+
vectors_config=VectorParams(
|
| 61 |
+
size=EMBEDDING_DIM,
|
| 62 |
+
distance=Distance.COSINE,
|
| 63 |
+
on_disk=False,
|
| 64 |
+
),
|
| 65 |
+
hnsw_config=HnswConfigDiff(
|
| 66 |
+
m=16,
|
| 67 |
+
ef_construct=100,
|
| 68 |
+
),
|
| 69 |
+
optimizers_config=OptimizersConfigDiff(
|
| 70 |
+
indexing_threshold=20000,
|
| 71 |
+
),
|
| 72 |
+
)
|
| 73 |
+
print("Collection created.")
|
| 74 |
+
|
| 75 |
+
print("Ensuring payload indexes for filtered search...")
|
| 76 |
+
for field in ("section", "policy_name", "plan_type", "doc_type", "provider"):
|
| 77 |
+
client.create_payload_index(
|
| 78 |
+
collection_name=QDRANT_COLLECTION,
|
| 79 |
+
field_name=field,
|
| 80 |
+
field_schema=PayloadSchemaType.KEYWORD,
|
| 81 |
+
)
|
| 82 |
+
print(" Indexes created: section, policy_name, plan_type, doc_type, provider")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_data():
|
| 86 |
+
print("Loading embeddings...")
|
| 87 |
+
data = np.load(EMBEDDINGS_FILE, allow_pickle=True)
|
| 88 |
+
ids = data["ids"]
|
| 89 |
+
embeddings = data["embeddings"]
|
| 90 |
+
print(f" Loaded {len(ids)} embeddings of dim {embeddings.shape[1]}")
|
| 91 |
+
|
| 92 |
+
print("Loading chunk metadata...")
|
| 93 |
+
with open(RAG_CHUNKS_PATH, "r", encoding="utf-8") as f:
|
| 94 |
+
chunks = json.load(f)
|
| 95 |
+
|
| 96 |
+
chunk_map = {c["id"]: c for c in chunks}
|
| 97 |
+
print(f" Loaded {len(chunks)} chunks")
|
| 98 |
+
|
| 99 |
+
return ids, embeddings, chunk_map
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def build_payload(chunk: dict) -> dict:
|
| 103 |
+
return {
|
| 104 |
+
"policy_name": chunk.get("policy_name", ""),
|
| 105 |
+
"policy_number": chunk.get("policy_number", ""),
|
| 106 |
+
"effective_date": chunk.get("effective_date", ""),
|
| 107 |
+
"plan_type": chunk.get("plan_type", ""),
|
| 108 |
+
"doc_type": chunk.get("doc_type", ""),
|
| 109 |
+
"section": chunk.get("section", ""),
|
| 110 |
+
"page_start": chunk.get("page_start", 0),
|
| 111 |
+
"page_end": chunk.get("page_end", 0),
|
| 112 |
+
"chunk_index": chunk.get("chunk_index", 0),
|
| 113 |
+
"total_chunks_in_section": chunk.get("total_chunks_in_section", 0),
|
| 114 |
+
"text": chunk.get("text", ""),
|
| 115 |
+
"provider": PROVIDER_SLUG,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def upsert_points(client, ids, embeddings, chunk_map):
|
| 120 |
+
points = []
|
| 121 |
+
skipped = 0
|
| 122 |
+
|
| 123 |
+
for i, (chunk_id, vector) in enumerate(zip(ids, embeddings)):
|
| 124 |
+
chunk_id_str = str(chunk_id)
|
| 125 |
+
if chunk_id_str not in chunk_map:
|
| 126 |
+
skipped += 1
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
payload = build_payload(chunk_map[chunk_id_str])
|
| 130 |
+
|
| 131 |
+
points.append(
|
| 132 |
+
PointStruct(
|
| 133 |
+
id=i,
|
| 134 |
+
vector=vector.tolist(),
|
| 135 |
+
payload=payload,
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if skipped:
|
| 140 |
+
print(f" Skipped {skipped} embeddings (no matching chunk metadata)")
|
| 141 |
+
|
| 142 |
+
print(f" Upserting {len(points)} points in batches of {UPSERT_BATCH_SIZE}...")
|
| 143 |
+
|
| 144 |
+
for batch_start in tqdm(range(0, len(points), UPSERT_BATCH_SIZE), desc="Upserting"):
|
| 145 |
+
batch = points[batch_start : batch_start + UPSERT_BATCH_SIZE]
|
| 146 |
+
client.upsert(collection_name=QDRANT_COLLECTION, points=batch, wait=True)
|
| 147 |
+
|
| 148 |
+
return len(points)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def main():
|
| 152 |
+
parser = argparse.ArgumentParser(description="Store embeddings in Qdrant")
|
| 153 |
+
parser.add_argument("--recreate", action="store_true", help="Drop and recreate collection")
|
| 154 |
+
args = parser.parse_args()
|
| 155 |
+
|
| 156 |
+
client = get_client()
|
| 157 |
+
ensure_collection(client, recreate=args.recreate)
|
| 158 |
+
|
| 159 |
+
ids, embeddings, chunk_map = load_data()
|
| 160 |
+
total = upsert_points(client, ids, embeddings, chunk_map)
|
| 161 |
+
|
| 162 |
+
info = client.get_collection(QDRANT_COLLECTION)
|
| 163 |
+
print(f"\nDone. Collection '{QDRANT_COLLECTION}' now has {info.points_count} points.")
|
| 164 |
+
print(f" Vectors dim: {EMBEDDING_DIM}")
|
| 165 |
+
print(f" Distance: COSINE")
|
| 166 |
+
print(f" Provider: {PROVIDER_NAME}")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
if __name__ == "__main__":
|
| 170 |
+
main()
|
embedding/scripts/test_retrieval.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Batch retrieval test suite — loads the model once and runs 10 test queries
|
| 3 |
+
against Qdrant, graded by difficulty (4 easy, 3 medium, 3 hard).
|
| 4 |
+
|
| 5 |
+
Each test specifies:
|
| 6 |
+
- query: natural-language question a doctor/staff might ask
|
| 7 |
+
- expected_policy: slug that MUST appear in the top-K results
|
| 8 |
+
- expected_section: section that SHOULD appear for the best hit
|
| 9 |
+
- filters: optional section/policy filter to exercise filtered search
|
| 10 |
+
- difficulty: easy | medium | hard
|
| 11 |
+
|
| 12 |
+
Scoring:
|
| 13 |
+
- policy_hit@K : expected policy appears anywhere in top-K
|
| 14 |
+
- policy_hit@1 : expected policy is the rank-1 result
|
| 15 |
+
- section_match : rank-1 result matches expected section
|
| 16 |
+
- mrr : 1/rank of first correct-policy hit (mean reciprocal rank)
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python test_retrieval.py
|
| 20 |
+
python test_retrieval.py --top-k 5
|
| 21 |
+
python test_retrieval.py --verbose
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
from dataclasses import dataclass, field
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
from sentence_transformers import SentenceTransformer
|
| 32 |
+
from qdrant_client import QdrantClient
|
| 33 |
+
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
| 34 |
+
|
| 35 |
+
from config import (
|
| 36 |
+
EMBEDDING_MODEL_NAME,
|
| 37 |
+
MAX_SEQ_LENGTH,
|
| 38 |
+
QDRANT_HOST,
|
| 39 |
+
QDRANT_PORT,
|
| 40 |
+
QDRANT_COLLECTION,
|
| 41 |
+
QDRANT_URL,
|
| 42 |
+
QDRANT_API_KEY,
|
| 43 |
+
TOP_K,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# ── Test cases ────────────────────────────────────────────────────────────────
|
| 47 |
+
|
| 48 |
+
TEST_CASES = [
|
| 49 |
+
# ── EASY (4): direct keyword overlap, single policy, obvious answer ──────
|
| 50 |
+
{
|
| 51 |
+
"id": "E1",
|
| 52 |
+
"difficulty": "easy",
|
| 53 |
+
"query": "Is bariatric surgery covered for patients with BMI over 40?",
|
| 54 |
+
"expected_policy": "bariatric-surgery",
|
| 55 |
+
"expected_section": "Coverage Rationale",
|
| 56 |
+
"filters": {},
|
| 57 |
+
"rationale": "Direct policy name in query; BMI 40 threshold explicitly stated in Coverage Rationale.",
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "E2",
|
| 61 |
+
"difficulty": "easy",
|
| 62 |
+
"query": "What conditions are treated with hyperbaric oxygen therapy?",
|
| 63 |
+
"expected_policy": "hyperbaric-topical-oxygen-therapy",
|
| 64 |
+
"expected_section": "Coverage Rationale",
|
| 65 |
+
"filters": {},
|
| 66 |
+
"rationale": "Policy lists conditions (crush injury, osteomyelitis, etc.) directly in Coverage Rationale.",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"id": "E3",
|
| 70 |
+
"difficulty": "easy",
|
| 71 |
+
"query": "What is the coverage policy for cochlear implants in adults?",
|
| 72 |
+
"expected_policy": "cochlear-implants",
|
| 73 |
+
"expected_section": "Coverage Rationale",
|
| 74 |
+
"filters": {},
|
| 75 |
+
"rationale": "Exact policy name; Coverage Rationale states criteria for adults 18+.",
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "E4",
|
| 79 |
+
"difficulty": "easy",
|
| 80 |
+
"query": "Is TENS covered for pain management?",
|
| 81 |
+
"expected_policy": "electrical-stimulation-treatment-pain-muscle-rehabilitation",
|
| 82 |
+
"expected_section": "Coverage Rationale",
|
| 83 |
+
"filters": {},
|
| 84 |
+
"rationale": "TENS is the primary device discussed in this policy's Coverage Rationale.",
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
# ── MEDIUM (3): requires semantic understanding, cross-section, or filter ─
|
| 88 |
+
{
|
| 89 |
+
"id": "M1",
|
| 90 |
+
"difficulty": "medium",
|
| 91 |
+
"query": "What are the eligibility criteria for gene therapy in hemophilia B patients?",
|
| 92 |
+
"expected_policy": "gene-therapies-hemophilia",
|
| 93 |
+
"expected_section": "Coverage Rationale",
|
| 94 |
+
"filters": {},
|
| 95 |
+
"rationale": "Must match 'hemophilia B' to Beqvez criteria; query uses 'eligibility' not 'coverage'.",
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "M2",
|
| 99 |
+
"difficulty": "medium",
|
| 100 |
+
"query": "When is proton beam radiation approved instead of standard radiation for cancer?",
|
| 101 |
+
"expected_policy": "proton-beam-radiation-therapy",
|
| 102 |
+
"expected_section": "Coverage Rationale",
|
| 103 |
+
"filters": {},
|
| 104 |
+
"rationale": "Requires understanding that PBRT is an alternative; policy specifies indications by age and tumor type.",
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"id": "M3",
|
| 108 |
+
"difficulty": "medium",
|
| 109 |
+
"query": "Does UHC cover continuous glucose monitors for diabetic patients on insulin pumps?",
|
| 110 |
+
"expected_policy": "continuous-glucose-monitoring-insulin-delivery-managing-diabetes",
|
| 111 |
+
"expected_section": "Coverage Rationale",
|
| 112 |
+
"filters": {},
|
| 113 |
+
"rationale": "Long policy slug; query combines two sub-topics (CGM + insulin delivery) from the same policy.",
|
| 114 |
+
},
|
| 115 |
+
|
| 116 |
+
# ── HARD (3): paraphrased, multi-hop, or requires domain reasoning ────────
|
| 117 |
+
{
|
| 118 |
+
"id": "H1",
|
| 119 |
+
"difficulty": "hard",
|
| 120 |
+
"query": "A 16-year-old patient needs genetic testing for an undiagnosed developmental disorder — is whole genome sequencing covered?",
|
| 121 |
+
"expected_policy": "whole-exome-and-whole-genome-sequencing",
|
| 122 |
+
"expected_section": "Coverage Rationale",
|
| 123 |
+
"filters": {},
|
| 124 |
+
"rationale": "Heavily paraphrased; must link 'undiagnosed developmental disorder' + 'genetic testing' to WES/WGS policy criteria about suspected genetic cause.",
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"id": "H2",
|
| 128 |
+
"difficulty": "hard",
|
| 129 |
+
"query": "What documentation is needed before a patient can get gender-affirming mastectomy?",
|
| 130 |
+
"expected_policy": "gender-dysphoria-treatment",
|
| 131 |
+
"expected_section": "Coverage Rationale",
|
| 132 |
+
"filters": {},
|
| 133 |
+
"rationale": "Uses 'gender-affirming mastectomy' instead of 'Gender Dysphoria'; must connect to breast surgery documentation requirements in Coverage Rationale.",
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"id": "H3",
|
| 137 |
+
"difficulty": "hard",
|
| 138 |
+
"query": "Patient has failed oral appliance therapy for sleep apnea — what surgical options does UHC cover?",
|
| 139 |
+
"expected_policy": "obstructive-sleep-apnea-treatment",
|
| 140 |
+
"expected_section": "Coverage Rationale",
|
| 141 |
+
"filters": {},
|
| 142 |
+
"rationale": "Multi-hop reasoning: failed OAT → surgical alternatives; query never uses 'obstructive' or policy name. Must infer from clinical scenario.",
|
| 143 |
+
},
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 148 |
+
|
| 149 |
+
MAX_RETRIES = 3
|
| 150 |
+
RETRY_BACKOFF = 2
|
| 151 |
+
|
| 152 |
+
PASS = "\033[92m✓ PASS\033[0m"
|
| 153 |
+
FAIL = "\033[91m✗ FAIL\033[0m"
|
| 154 |
+
WARN = "\033[93m~ PARTIAL\033[0m"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def get_client() -> QdrantClient:
|
| 158 |
+
if QDRANT_URL:
|
| 159 |
+
return QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=30, prefer_grpc=False)
|
| 160 |
+
return QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT, timeout=30)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def run_query(client, model, device, query, top_k, section_filter=None, policy_filter=None):
|
| 164 |
+
vec = model.encode(query, convert_to_numpy=True, normalize_embeddings=True, device=device).tolist()
|
| 165 |
+
|
| 166 |
+
conditions = []
|
| 167 |
+
if section_filter:
|
| 168 |
+
conditions.append(FieldCondition(key="section", match=MatchValue(value=section_filter)))
|
| 169 |
+
if policy_filter:
|
| 170 |
+
conditions.append(FieldCondition(key="policy_name", match=MatchValue(value=policy_filter)))
|
| 171 |
+
|
| 172 |
+
qf = Filter(must=conditions) if conditions else None
|
| 173 |
+
|
| 174 |
+
for attempt in range(1, MAX_RETRIES + 1):
|
| 175 |
+
try:
|
| 176 |
+
return client.query_points(
|
| 177 |
+
collection_name=QDRANT_COLLECTION,
|
| 178 |
+
query=vec,
|
| 179 |
+
query_filter=qf,
|
| 180 |
+
limit=top_k,
|
| 181 |
+
with_payload=True,
|
| 182 |
+
).points
|
| 183 |
+
except Exception as e:
|
| 184 |
+
if attempt < MAX_RETRIES:
|
| 185 |
+
time.sleep(RETRY_BACKOFF ** attempt)
|
| 186 |
+
else:
|
| 187 |
+
raise RuntimeError(f"Qdrant query failed after {MAX_RETRIES} retries: {e}") from e
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@dataclass
|
| 191 |
+
class TestResult:
|
| 192 |
+
test_id: str
|
| 193 |
+
difficulty: str
|
| 194 |
+
query: str
|
| 195 |
+
expected_policy: str
|
| 196 |
+
expected_section: str
|
| 197 |
+
policy_hit_at_k: bool = False
|
| 198 |
+
policy_hit_at_1: bool = False
|
| 199 |
+
section_match: bool = False
|
| 200 |
+
first_hit_rank: int = 0
|
| 201 |
+
top1_policy: str = ""
|
| 202 |
+
top1_section: str = ""
|
| 203 |
+
top1_score: float = 0.0
|
| 204 |
+
latency_ms: float = 0.0
|
| 205 |
+
error: str = ""
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def evaluate(tc: dict, client, model, device, top_k: int) -> TestResult:
|
| 209 |
+
res = TestResult(
|
| 210 |
+
test_id=tc["id"],
|
| 211 |
+
difficulty=tc["difficulty"],
|
| 212 |
+
query=tc["query"],
|
| 213 |
+
expected_policy=tc["expected_policy"],
|
| 214 |
+
expected_section=tc["expected_section"],
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
t0 = time.perf_counter()
|
| 218 |
+
try:
|
| 219 |
+
hits = run_query(
|
| 220 |
+
client, model, device,
|
| 221 |
+
tc["query"], top_k,
|
| 222 |
+
tc["filters"].get("section"),
|
| 223 |
+
tc["filters"].get("policy"),
|
| 224 |
+
)
|
| 225 |
+
except RuntimeError as e:
|
| 226 |
+
res.error = str(e)
|
| 227 |
+
res.latency_ms = (time.perf_counter() - t0) * 1000
|
| 228 |
+
return res
|
| 229 |
+
|
| 230 |
+
res.latency_ms = (time.perf_counter() - t0) * 1000
|
| 231 |
+
|
| 232 |
+
if not hits:
|
| 233 |
+
return res
|
| 234 |
+
|
| 235 |
+
res.top1_policy = hits[0].payload.get("policy_name", "")
|
| 236 |
+
res.top1_section = hits[0].payload.get("section", "")
|
| 237 |
+
res.top1_score = hits[0].score
|
| 238 |
+
|
| 239 |
+
res.policy_hit_at_1 = res.top1_policy == tc["expected_policy"]
|
| 240 |
+
res.section_match = res.top1_section == tc["expected_section"]
|
| 241 |
+
|
| 242 |
+
for rank, hit in enumerate(hits, 1):
|
| 243 |
+
if hit.payload.get("policy_name") == tc["expected_policy"]:
|
| 244 |
+
res.policy_hit_at_k = True
|
| 245 |
+
res.first_hit_rank = rank
|
| 246 |
+
break
|
| 247 |
+
|
| 248 |
+
return res
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
| 252 |
+
|
| 253 |
+
def main():
|
| 254 |
+
parser = argparse.ArgumentParser(description="Batch retrieval test suite")
|
| 255 |
+
parser.add_argument("--top-k", type=int, default=TOP_K)
|
| 256 |
+
parser.add_argument("--verbose", "-v", action="store_true", help="Print top-3 results per test")
|
| 257 |
+
args = parser.parse_args()
|
| 258 |
+
|
| 259 |
+
print("=" * 80)
|
| 260 |
+
print(" UHC Policy RAG — Retrieval Test Suite")
|
| 261 |
+
print(f" Model: {EMBEDDING_MODEL_NAME} | Top-K: {args.top_k}")
|
| 262 |
+
print("=" * 80)
|
| 263 |
+
|
| 264 |
+
print("\nLoading model (one-time)...")
|
| 265 |
+
device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
|
| 266 |
+
model = SentenceTransformer(EMBEDDING_MODEL_NAME, trust_remote_code=False)
|
| 267 |
+
model.max_seq_length = MAX_SEQ_LENGTH
|
| 268 |
+
print(f" Model loaded on {device}")
|
| 269 |
+
|
| 270 |
+
print("Connecting to Qdrant...\n")
|
| 271 |
+
client = get_client()
|
| 272 |
+
|
| 273 |
+
results: list[TestResult] = []
|
| 274 |
+
|
| 275 |
+
for tc in TEST_CASES:
|
| 276 |
+
r = evaluate(tc, client, model, device, args.top_k)
|
| 277 |
+
results.append(r)
|
| 278 |
+
|
| 279 |
+
if r.error:
|
| 280 |
+
status = f"\033[91mERROR\033[0m"
|
| 281 |
+
elif r.policy_hit_at_1 and r.section_match:
|
| 282 |
+
status = PASS
|
| 283 |
+
elif r.policy_hit_at_k:
|
| 284 |
+
status = WARN
|
| 285 |
+
else:
|
| 286 |
+
status = FAIL
|
| 287 |
+
|
| 288 |
+
print(f" [{r.test_id}] {status} ({r.difficulty.upper():6s}) {r.latency_ms:6.0f}ms "
|
| 289 |
+
f"score={r.top1_score:.4f} {r.query[:60]}...")
|
| 290 |
+
|
| 291 |
+
if r.error:
|
| 292 |
+
print(f" ERROR: {r.error[:120]}")
|
| 293 |
+
elif not r.policy_hit_at_1:
|
| 294 |
+
print(f" Expected: {r.expected_policy} / {r.expected_section}")
|
| 295 |
+
print(f" Got top1: {r.top1_policy} / {r.top1_section}")
|
| 296 |
+
if r.policy_hit_at_k:
|
| 297 |
+
print(f" Correct policy first found at rank #{r.first_hit_rank}")
|
| 298 |
+
|
| 299 |
+
if args.verbose and not r.error:
|
| 300 |
+
hits = run_query(
|
| 301 |
+
client, model, device,
|
| 302 |
+
tc["query"], min(3, args.top_k),
|
| 303 |
+
tc["filters"].get("section"),
|
| 304 |
+
tc["filters"].get("policy"),
|
| 305 |
+
)
|
| 306 |
+
for rank, hit in enumerate(hits, 1):
|
| 307 |
+
p = hit.payload
|
| 308 |
+
print(f" #{rank} [{hit.score:.4f}] {p.get('policy_name')} / {p.get('section')} | {p.get('text')}")
|
| 309 |
+
|
| 310 |
+
# ── Summary ───────────────────────────────────────────────────────────
|
| 311 |
+
print("\n" + "=" * 80)
|
| 312 |
+
print(" SUMMARY")
|
| 313 |
+
print("=" * 80)
|
| 314 |
+
|
| 315 |
+
valid = [r for r in results if not r.error]
|
| 316 |
+
errored = [r for r in results if r.error]
|
| 317 |
+
|
| 318 |
+
if not valid:
|
| 319 |
+
print(" All tests errored. Check Qdrant connection / API key.")
|
| 320 |
+
sys.exit(1)
|
| 321 |
+
|
| 322 |
+
hit_at_1 = sum(1 for r in valid if r.policy_hit_at_1)
|
| 323 |
+
hit_at_k = sum(1 for r in valid if r.policy_hit_at_k)
|
| 324 |
+
section_ok = sum(1 for r in valid if r.policy_hit_at_1 and r.section_match)
|
| 325 |
+
mrr = sum((1.0 / r.first_hit_rank) for r in valid if r.first_hit_rank > 0) / len(valid)
|
| 326 |
+
avg_latency = sum(r.latency_ms for r in valid) / len(valid)
|
| 327 |
+
avg_score = sum(r.top1_score for r in valid) / len(valid)
|
| 328 |
+
|
| 329 |
+
print(f"\n Total tests: {len(results)}")
|
| 330 |
+
print(f" Successful: {len(valid)}")
|
| 331 |
+
if errored:
|
| 332 |
+
print(f" Errors: {len(errored)} ({', '.join(r.test_id for r in errored)})")
|
| 333 |
+
print(f"\n Policy Hit@1: {hit_at_1}/{len(valid)} ({100*hit_at_1/len(valid):.0f}%)")
|
| 334 |
+
print(f" Policy Hit@K: {hit_at_k}/{len(valid)} ({100*hit_at_k/len(valid):.0f}%)")
|
| 335 |
+
print(f" Section Match@1: {section_ok}/{len(valid)} ({100*section_ok/len(valid):.0f}%)")
|
| 336 |
+
print(f" MRR: {mrr:.4f}")
|
| 337 |
+
print(f" Avg Cosine Score: {avg_score:.4f}")
|
| 338 |
+
print(f" Avg Latency: {avg_latency:.0f}ms")
|
| 339 |
+
|
| 340 |
+
for difficulty in ("easy", "medium", "hard"):
|
| 341 |
+
subset = [r for r in valid if r.difficulty == difficulty]
|
| 342 |
+
if not subset:
|
| 343 |
+
continue
|
| 344 |
+
h1 = sum(1 for r in subset if r.policy_hit_at_1)
|
| 345 |
+
hk = sum(1 for r in subset if r.policy_hit_at_k)
|
| 346 |
+
sub_mrr = sum((1.0 / r.first_hit_rank) for r in subset if r.first_hit_rank > 0) / len(subset)
|
| 347 |
+
print(f"\n {difficulty.upper():6s} Hit@1: {h1}/{len(subset)} Hit@K: {hk}/{len(subset)} MRR: {sub_mrr:.4f}")
|
| 348 |
+
|
| 349 |
+
print("\n" + "=" * 80)
|
| 350 |
+
|
| 351 |
+
# ── JSON dump for programmatic use ────────────────────────────────────
|
| 352 |
+
report = {
|
| 353 |
+
"model": EMBEDDING_MODEL_NAME,
|
| 354 |
+
"top_k": args.top_k,
|
| 355 |
+
"total": len(results),
|
| 356 |
+
"policy_hit_at_1": hit_at_1,
|
| 357 |
+
"policy_hit_at_k": hit_at_k,
|
| 358 |
+
"section_match_at_1": section_ok,
|
| 359 |
+
"mrr": round(mrr, 4),
|
| 360 |
+
"avg_cosine_score": round(avg_score, 4),
|
| 361 |
+
"avg_latency_ms": round(avg_latency, 1),
|
| 362 |
+
"tests": [
|
| 363 |
+
{
|
| 364 |
+
"id": r.test_id,
|
| 365 |
+
"difficulty": r.difficulty,
|
| 366 |
+
"query": r.query,
|
| 367 |
+
"policy_hit_at_1": r.policy_hit_at_1,
|
| 368 |
+
"policy_hit_at_k": r.policy_hit_at_k,
|
| 369 |
+
"section_match": r.section_match,
|
| 370 |
+
"first_hit_rank": r.first_hit_rank,
|
| 371 |
+
"top1_score": round(r.top1_score, 4),
|
| 372 |
+
"latency_ms": round(r.latency_ms, 1),
|
| 373 |
+
"error": r.error or None,
|
| 374 |
+
}
|
| 375 |
+
for r in results
|
| 376 |
+
],
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
out_path = "test_results.json"
|
| 380 |
+
with open(out_path, "w") as f:
|
| 381 |
+
json.dump(report, f, indent=2)
|
| 382 |
+
print(f" Results saved to {out_path}\n")
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
if __name__ == "__main__":
|
| 386 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PDF scraping & extraction
|
| 2 |
+
pdfplumber>=0.10.0
|
| 3 |
+
beautifulsoup4>=4.12.0
|
| 4 |
+
requests>=2.31.0
|
| 5 |
+
tqdm>=4.66.0
|
| 6 |
+
pandas>=2.0.0
|
| 7 |
+
|
| 8 |
+
# Embedding model
|
| 9 |
+
sentence-transformers>=3.0.0
|
| 10 |
+
torch>=2.0.0
|
| 11 |
+
transformers>=4.40.0
|
| 12 |
+
numpy>=1.24.0
|
| 13 |
+
|
| 14 |
+
# Vector database
|
| 15 |
+
qdrant-client>=1.9.0
|
| 16 |
+
|
| 17 |
+
# LLM providers
|
| 18 |
+
groq>=0.9.0
|
| 19 |
+
|
| 20 |
+
# Web UI
|
| 21 |
+
streamlit>=1.35.0
|
| 22 |
+
|
| 23 |
+
# Utilities
|
| 24 |
+
python-dotenv>=1.0.0
|
scraper/create_rag_chunks.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
import hashlib
|
| 4 |
+
from tqdm import tqdm
|
| 5 |
+
|
| 6 |
+
INPUT_FILE = "data/processed/extracted_sections.json"
|
| 7 |
+
OUTPUT_FILE = "data/processed/rag_chunks.json"
|
| 8 |
+
|
| 9 |
+
TARGET_CHUNK_TOKENS = 400
|
| 10 |
+
MAX_CHUNK_TOKENS = 600
|
| 11 |
+
OVERLAP_SENTENCES = 2
|
| 12 |
+
|
| 13 |
+
LOW_VALUE_SECTIONS = {"References", "U.S. Food and Drug Administration"}
|
| 14 |
+
BOILERPLATE_SECTIONS = {"Instructions for Use", "Policy History/Revision Information"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def estimate_tokens(text):
|
| 18 |
+
return int(len(text.split()) * 1.3)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def split_sentences(text):
|
| 22 |
+
parts = re.split(r'(?<=[.;])\s+(?=[A-Z])', text)
|
| 23 |
+
sentences = []
|
| 24 |
+
for part in parts:
|
| 25 |
+
if estimate_tokens(part) > MAX_CHUNK_TOKENS:
|
| 26 |
+
sub_parts = re.split(r'(?<=[:;])\s+', part)
|
| 27 |
+
sentences.extend(sub_parts)
|
| 28 |
+
else:
|
| 29 |
+
sentences.append(part)
|
| 30 |
+
return [s.strip() for s in sentences if s.strip()]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def chunk_id(policy, section, idx):
|
| 34 |
+
raw = f"{policy}__{section}__{idx}"
|
| 35 |
+
return hashlib.md5(raw.encode()).hexdigest()[:12]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def chunk_by_criteria(text, policy_name):
|
| 39 |
+
blocks = re.split(
|
| 40 |
+
r'\n\n(?=(?:The following|For (?:initial|continuation|subsequent|revision|replacement)|'
|
| 41 |
+
r'(?:An?|The)\s+\w.*?is (?:proven|unproven|medically necessary|not medically)|'
|
| 42 |
+
r'(?:Multiplex|Implantable|Removable|Emergency|Non-Surgical|Surgical)))',
|
| 43 |
+
text
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
if len(blocks) <= 1:
|
| 47 |
+
blocks = re.split(r'\n\n', text)
|
| 48 |
+
|
| 49 |
+
result = []
|
| 50 |
+
current = []
|
| 51 |
+
current_tokens = 0
|
| 52 |
+
|
| 53 |
+
for block in blocks:
|
| 54 |
+
block = block.strip()
|
| 55 |
+
if not block:
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
block_tokens = estimate_tokens(block)
|
| 59 |
+
|
| 60 |
+
if block_tokens > MAX_CHUNK_TOKENS:
|
| 61 |
+
if current:
|
| 62 |
+
result.append("\n\n".join(current))
|
| 63 |
+
current = []
|
| 64 |
+
current_tokens = 0
|
| 65 |
+
|
| 66 |
+
sents = split_sentences(block)
|
| 67 |
+
sent_group = []
|
| 68 |
+
sent_tokens = 0
|
| 69 |
+
for sent in sents:
|
| 70 |
+
st = estimate_tokens(sent)
|
| 71 |
+
if sent_tokens + st > TARGET_CHUNK_TOKENS and sent_group:
|
| 72 |
+
result.append(" ".join(sent_group))
|
| 73 |
+
overlap = sent_group[-OVERLAP_SENTENCES:] if len(sent_group) > OVERLAP_SENTENCES else []
|
| 74 |
+
sent_group = overlap
|
| 75 |
+
sent_tokens = sum(estimate_tokens(s) for s in sent_group)
|
| 76 |
+
sent_group.append(sent)
|
| 77 |
+
sent_tokens += st
|
| 78 |
+
if sent_group:
|
| 79 |
+
result.append(" ".join(sent_group))
|
| 80 |
+
|
| 81 |
+
elif current_tokens + block_tokens > TARGET_CHUNK_TOKENS and current:
|
| 82 |
+
result.append("\n\n".join(current))
|
| 83 |
+
current = [block]
|
| 84 |
+
current_tokens = block_tokens
|
| 85 |
+
else:
|
| 86 |
+
current.append(block)
|
| 87 |
+
current_tokens += block_tokens
|
| 88 |
+
|
| 89 |
+
if current:
|
| 90 |
+
result.append("\n\n".join(current))
|
| 91 |
+
|
| 92 |
+
return result
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def chunk_code_table(text):
|
| 96 |
+
lines = text.split("\n")
|
| 97 |
+
chunks = []
|
| 98 |
+
current_lines = []
|
| 99 |
+
current_tokens = 0
|
| 100 |
+
|
| 101 |
+
header_line = None
|
| 102 |
+
for line in lines:
|
| 103 |
+
stripped = line.strip()
|
| 104 |
+
if not stripped:
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
if re.match(r"^(?:CPT|HCPCS|Diagnosis|ICD-10)\s+(?:Code|Description)", stripped, re.IGNORECASE):
|
| 108 |
+
header_line = stripped
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
if re.match(r"^The following list\(s\)", stripped):
|
| 112 |
+
continue
|
| 113 |
+
if re.match(r"^CPT®?\s+is a registered", stripped):
|
| 114 |
+
continue
|
| 115 |
+
if re.match(r"^Listing of a code", stripped):
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
line_tokens = estimate_tokens(stripped)
|
| 119 |
+
|
| 120 |
+
if current_tokens + line_tokens > TARGET_CHUNK_TOKENS and current_lines:
|
| 121 |
+
chunk_text = "\n".join(current_lines)
|
| 122 |
+
if header_line:
|
| 123 |
+
chunk_text = header_line + "\n" + chunk_text
|
| 124 |
+
chunks.append(chunk_text)
|
| 125 |
+
current_lines = []
|
| 126 |
+
current_tokens = 0
|
| 127 |
+
|
| 128 |
+
current_lines.append(stripped)
|
| 129 |
+
current_tokens += line_tokens
|
| 130 |
+
|
| 131 |
+
if current_lines:
|
| 132 |
+
chunk_text = "\n".join(current_lines)
|
| 133 |
+
if header_line:
|
| 134 |
+
chunk_text = header_line + "\n" + chunk_text
|
| 135 |
+
chunks.append(chunk_text)
|
| 136 |
+
|
| 137 |
+
return chunks
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def chunk_clinical_evidence(text):
|
| 141 |
+
study_splits = re.split(
|
| 142 |
+
r'\n\n(?=(?:[A-Z][a-z]+(?:\s+(?:et al\.|and|&))?.*?\(\d{4}\))|'
|
| 143 |
+
r'(?:A\s+(?:phase|prospective|retrospective|randomized|multicenter|systematic|meta-analysis|Cochrane))|'
|
| 144 |
+
r'(?:Professional Societies|American|European|National|International))',
|
| 145 |
+
text
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
if len(study_splits) <= 1:
|
| 149 |
+
study_splits = text.split("\n\n")
|
| 150 |
+
|
| 151 |
+
chunks = []
|
| 152 |
+
current = []
|
| 153 |
+
current_tokens = 0
|
| 154 |
+
|
| 155 |
+
for block in study_splits:
|
| 156 |
+
block = block.strip()
|
| 157 |
+
if not block:
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
block_tokens = estimate_tokens(block)
|
| 161 |
+
|
| 162 |
+
if block_tokens > MAX_CHUNK_TOKENS:
|
| 163 |
+
if current:
|
| 164 |
+
chunks.append("\n\n".join(current))
|
| 165 |
+
current = []
|
| 166 |
+
current_tokens = 0
|
| 167 |
+
|
| 168 |
+
sents = split_sentences(block)
|
| 169 |
+
sent_group = []
|
| 170 |
+
sent_tokens = 0
|
| 171 |
+
for sent in sents:
|
| 172 |
+
st = estimate_tokens(sent)
|
| 173 |
+
if sent_tokens + st > TARGET_CHUNK_TOKENS and sent_group:
|
| 174 |
+
chunks.append(" ".join(sent_group))
|
| 175 |
+
overlap = sent_group[-OVERLAP_SENTENCES:] if len(sent_group) > OVERLAP_SENTENCES else []
|
| 176 |
+
sent_group = overlap
|
| 177 |
+
sent_tokens = sum(estimate_tokens(s) for s in sent_group)
|
| 178 |
+
sent_group.append(sent)
|
| 179 |
+
sent_tokens += st
|
| 180 |
+
if sent_group:
|
| 181 |
+
chunks.append(" ".join(sent_group))
|
| 182 |
+
|
| 183 |
+
elif current_tokens + block_tokens > MAX_CHUNK_TOKENS and current:
|
| 184 |
+
chunks.append("\n\n".join(current))
|
| 185 |
+
current = [block]
|
| 186 |
+
current_tokens = block_tokens
|
| 187 |
+
else:
|
| 188 |
+
current.append(block)
|
| 189 |
+
current_tokens += block_tokens
|
| 190 |
+
|
| 191 |
+
if current:
|
| 192 |
+
chunks.append("\n\n".join(current))
|
| 193 |
+
|
| 194 |
+
return chunks
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def chunk_section(section_name, content, policy_name):
|
| 198 |
+
if section_name in BOILERPLATE_SECTIONS:
|
| 199 |
+
return []
|
| 200 |
+
|
| 201 |
+
if section_name == "Applicable Codes" or section_name == "Coverage Summary":
|
| 202 |
+
return chunk_code_table(content)
|
| 203 |
+
|
| 204 |
+
if section_name == "Clinical Evidence":
|
| 205 |
+
return chunk_clinical_evidence(content)
|
| 206 |
+
|
| 207 |
+
if section_name in ("Coverage Rationale", "Application", "Definitions",
|
| 208 |
+
"Documentation Requirements", "Medical Records Documentation Used for Reviews"):
|
| 209 |
+
return chunk_by_criteria(content, policy_name)
|
| 210 |
+
|
| 211 |
+
tokens = estimate_tokens(content)
|
| 212 |
+
if tokens <= TARGET_CHUNK_TOKENS:
|
| 213 |
+
return [content]
|
| 214 |
+
|
| 215 |
+
return chunk_by_criteria(content, policy_name)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def main():
|
| 219 |
+
with open(INPUT_FILE, "r", encoding="utf-8") as f:
|
| 220 |
+
policies = json.load(f)
|
| 221 |
+
|
| 222 |
+
all_chunks = []
|
| 223 |
+
|
| 224 |
+
for policy in tqdm(policies, desc="Creating chunks"):
|
| 225 |
+
policy_name = policy["policy_name"]
|
| 226 |
+
policy_number = policy.get("policy_number", "")
|
| 227 |
+
effective_date = policy.get("effective_date", "")
|
| 228 |
+
plan_type = policy.get("plan_type", "")
|
| 229 |
+
doc_type = policy.get("doc_type", "")
|
| 230 |
+
|
| 231 |
+
for section_data in policy["sections"]:
|
| 232 |
+
section_name = section_data["section"]
|
| 233 |
+
content = section_data["content"]
|
| 234 |
+
page_start = section_data.get("page_start", 0)
|
| 235 |
+
page_end = section_data.get("page_end", 0)
|
| 236 |
+
|
| 237 |
+
if section_name in BOILERPLATE_SECTIONS:
|
| 238 |
+
continue
|
| 239 |
+
|
| 240 |
+
text_chunks = chunk_section(section_name, content, policy_name)
|
| 241 |
+
|
| 242 |
+
for idx, chunk_text in enumerate(text_chunks):
|
| 243 |
+
chunk_text = chunk_text.strip()
|
| 244 |
+
if not chunk_text or len(chunk_text) < 20:
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
all_chunks.append({
|
| 248 |
+
"id": chunk_id(policy_name, section_name, idx),
|
| 249 |
+
"policy_name": policy_name,
|
| 250 |
+
"policy_number": policy_number,
|
| 251 |
+
"effective_date": effective_date,
|
| 252 |
+
"plan_type": plan_type,
|
| 253 |
+
"doc_type": doc_type,
|
| 254 |
+
"section": section_name,
|
| 255 |
+
"page_start": page_start,
|
| 256 |
+
"page_end": page_end,
|
| 257 |
+
"chunk_index": idx,
|
| 258 |
+
"total_chunks_in_section": len(text_chunks),
|
| 259 |
+
"text": chunk_text,
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
| 263 |
+
json.dump(all_chunks, f, indent=2, ensure_ascii=False)
|
| 264 |
+
|
| 265 |
+
print(f"Total chunks: {len(all_chunks)}")
|
| 266 |
+
print(f"Policies processed: {len(policies)}")
|
| 267 |
+
print(f"Saved to: {OUTPUT_FILE}")
|
| 268 |
+
|
| 269 |
+
section_counts = {}
|
| 270 |
+
for c in all_chunks:
|
| 271 |
+
section_counts[c["section"]] = section_counts.get(c["section"], 0) + 1
|
| 272 |
+
print("\nChunks per section:")
|
| 273 |
+
for sec, count in sorted(section_counts.items(), key=lambda x: -x[1]):
|
| 274 |
+
print(f" {sec}: {count}")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
if __name__ == "__main__":
|
| 278 |
+
main()
|
scraper/download_policies.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
from bs4 import BeautifulSoup
|
| 4 |
+
from urllib.parse import urljoin
|
| 5 |
+
from tqdm import tqdm
|
| 6 |
+
|
| 7 |
+
BASE_URL = "https://www.uhcprovider.com"
|
| 8 |
+
PAGE_URL = "https://www.uhcprovider.com/en/policies-protocols/commercial-policies/commercial-medical-drug-policies.html"
|
| 9 |
+
|
| 10 |
+
SAVE_DIR = "data/pdfs"
|
| 11 |
+
os.makedirs(SAVE_DIR, exist_ok=True)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_pdf_links():
|
| 15 |
+
|
| 16 |
+
print("Fetching webpage...")
|
| 17 |
+
|
| 18 |
+
response = requests.get(PAGE_URL)
|
| 19 |
+
soup = BeautifulSoup(response.text, "html.parser")
|
| 20 |
+
|
| 21 |
+
pdf_links = []
|
| 22 |
+
|
| 23 |
+
for link in soup.find_all("a", href=True):
|
| 24 |
+
|
| 25 |
+
href = link["href"]
|
| 26 |
+
|
| 27 |
+
if ".pdf" in href.lower():
|
| 28 |
+
full_link = urljoin(BASE_URL, href)
|
| 29 |
+
name = link.text.strip()
|
| 30 |
+
|
| 31 |
+
pdf_links.append({
|
| 32 |
+
"name": name,
|
| 33 |
+
"url": full_link
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
print(f"Found {len(pdf_links)} PDF policies")
|
| 37 |
+
|
| 38 |
+
return pdf_links
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def download_pdfs(pdf_links):
|
| 42 |
+
|
| 43 |
+
for pdf in tqdm(pdf_links):
|
| 44 |
+
|
| 45 |
+
filename = pdf["url"].split("/")[-1]
|
| 46 |
+
path = os.path.join(SAVE_DIR, filename)
|
| 47 |
+
|
| 48 |
+
if os.path.exists(path):
|
| 49 |
+
continue
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
r = requests.get(pdf["url"], timeout=60)
|
| 53 |
+
|
| 54 |
+
with open(path, "wb") as f:
|
| 55 |
+
f.write(r.content)
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print("Failed:", pdf["url"], e)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
|
| 63 |
+
links = get_pdf_links()
|
| 64 |
+
|
| 65 |
+
download_pdfs(links)
|
| 66 |
+
|
| 67 |
+
print("Download complete")
|
scraper/extract_pdf_text.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import json
|
| 4 |
+
import pdfplumber
|
| 5 |
+
from tqdm import tqdm
|
| 6 |
+
from bs4 import BeautifulSoup
|
| 7 |
+
from dataclasses import dataclass, field, asdict
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
PDF_DIR = "data/pdfs"
|
| 11 |
+
OUTPUT_FILE = "data/processed/extracted_sections.json"
|
| 12 |
+
|
| 13 |
+
SKIP_FILES = {
|
| 14 |
+
"TOU-UHCPROVIDER-COM-EN.pdf",
|
| 15 |
+
"OSPP-UHCPROVIDER-COM-EN.pdf",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
SECTION_HEADERS = [
|
| 19 |
+
"Instructions for Use",
|
| 20 |
+
"Coverage Rationale",
|
| 21 |
+
"Coverage Summary",
|
| 22 |
+
"Application",
|
| 23 |
+
"Medical Records Documentation Used for Reviews",
|
| 24 |
+
"Documentation Requirements",
|
| 25 |
+
"Definitions",
|
| 26 |
+
"Applicable Codes",
|
| 27 |
+
"Description of Services",
|
| 28 |
+
"Benefit Considerations",
|
| 29 |
+
"Clinical Evidence",
|
| 30 |
+
"Background",
|
| 31 |
+
"U.S. Food and Drug Administration",
|
| 32 |
+
"Centers for Medicare and Medicaid Services",
|
| 33 |
+
"References",
|
| 34 |
+
"Policy History/Revision Information",
|
| 35 |
+
"Frequently Asked Questions",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
SKIP_SECTIONS = {
|
| 39 |
+
"Instructions for Use",
|
| 40 |
+
"Policy History/Revision Information",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
PAGE_HEADER_PATTERNS = [
|
| 44 |
+
re.compile(r"^.{0,120}Page\s+\d+\s+of\s+\d+\s*$"),
|
| 45 |
+
re.compile(r"^UnitedHealthcare.*(?:Medical|Drug)\s+(?:Policy|Benefit).*(?:Effective|Policy)"),
|
| 46 |
+
re.compile(r"^Proprietary Information of UnitedHealthcare"),
|
| 47 |
+
re.compile(r"^©\s*\d{4}"),
|
| 48 |
+
re.compile(r"^Effective\s+\d{2}/\d{2}/\d{4}\s*$"),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
SIDEBAR_PATTERNS = [
|
| 52 |
+
re.compile(r"^(?:Related\s+)?(?:Commercial|Community\s+Plan|Medicare\s+Advantage)\s+(?:Policy|Policies)", re.IGNORECASE),
|
| 53 |
+
re.compile(r"^Related\s+(?:Commercial|List)", re.IGNORECASE),
|
| 54 |
+
re.compile(r"^Medicare\s+Advantage\s+Policy", re.IGNORECASE),
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
POLICY_NUMBER_RE = re.compile(r"Policy\s+Number:\s*(\S+)")
|
| 58 |
+
EFFECTIVE_DATE_RE = re.compile(r"Effective\s+Date:\s*(.+?)(?:\s{2,}|$)")
|
| 59 |
+
PLAN_TYPE_RE = re.compile(r"UnitedHealthcare®?\s+(Commercial.*?)$", re.MULTILINE)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class PolicySection:
|
| 64 |
+
section: str
|
| 65 |
+
content: str
|
| 66 |
+
page_start: int
|
| 67 |
+
page_end: int
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class PolicyDocument:
|
| 72 |
+
filename: str
|
| 73 |
+
policy_name: str
|
| 74 |
+
policy_number: str
|
| 75 |
+
effective_date: str
|
| 76 |
+
plan_type: str
|
| 77 |
+
doc_type: str
|
| 78 |
+
sections: list = field(default_factory=list)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def is_html_file(path):
|
| 82 |
+
try:
|
| 83 |
+
with open(path, "rb") as f:
|
| 84 |
+
start = f.read(200).decode(errors="ignore").lower()
|
| 85 |
+
return "<html" in start or "<!doctype html" in start
|
| 86 |
+
except Exception:
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def is_page_header(line):
|
| 91 |
+
stripped = line.strip()
|
| 92 |
+
if not stripped:
|
| 93 |
+
return True
|
| 94 |
+
for pat in PAGE_HEADER_PATTERNS:
|
| 95 |
+
if pat.search(stripped):
|
| 96 |
+
return True
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def is_toc_line(line):
|
| 101 |
+
stripped = line.strip()
|
| 102 |
+
if re.match(r"^Table of Contents\s*Page?\s*$", stripped, re.IGNORECASE):
|
| 103 |
+
return True
|
| 104 |
+
if re.match(r"^.{3,80}\s*\.{3,}\s*\d+\s*$", stripped):
|
| 105 |
+
return True
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def is_sidebar_start(line):
|
| 110 |
+
stripped = line.strip()
|
| 111 |
+
for pat in SIDEBAR_PATTERNS:
|
| 112 |
+
if pat.match(stripped):
|
| 113 |
+
return True
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def detect_section(line):
|
| 118 |
+
stripped = line.strip()
|
| 119 |
+
for header in SECTION_HEADERS:
|
| 120 |
+
if stripped == header or stripped.startswith(header + "\n"):
|
| 121 |
+
return header
|
| 122 |
+
if re.match(re.escape(header) + r"\s*$", stripped):
|
| 123 |
+
return header
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def extract_metadata(full_text, filename):
|
| 128 |
+
policy_name = os.path.basename(filename).replace(".pdf", "")
|
| 129 |
+
|
| 130 |
+
policy_number = ""
|
| 131 |
+
m = POLICY_NUMBER_RE.search(full_text[:2000])
|
| 132 |
+
if m:
|
| 133 |
+
policy_number = m.group(1).strip()
|
| 134 |
+
|
| 135 |
+
effective_date = ""
|
| 136 |
+
m = EFFECTIVE_DATE_RE.search(full_text[:2000])
|
| 137 |
+
if m:
|
| 138 |
+
effective_date = m.group(1).strip()
|
| 139 |
+
|
| 140 |
+
plan_type = ""
|
| 141 |
+
m = PLAN_TYPE_RE.search(full_text[:1000])
|
| 142 |
+
if m:
|
| 143 |
+
plan_type = m.group(1).strip()
|
| 144 |
+
|
| 145 |
+
doc_type = "Medical Policy"
|
| 146 |
+
if "Medical Benefit Drug Policy" in full_text[:1000]:
|
| 147 |
+
doc_type = "Medical Benefit Drug Policy"
|
| 148 |
+
elif "Medical Policy Update Bulletin" in full_text[:500]:
|
| 149 |
+
doc_type = "Update Bulletin"
|
| 150 |
+
|
| 151 |
+
return policy_name, policy_number, effective_date, plan_type, doc_type
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def clean_page_text(text):
|
| 155 |
+
lines = text.split("\n")
|
| 156 |
+
cleaned = []
|
| 157 |
+
in_sidebar = False
|
| 158 |
+
in_toc = False
|
| 159 |
+
|
| 160 |
+
for line in lines:
|
| 161 |
+
if is_page_header(line):
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
if is_toc_line(line):
|
| 165 |
+
in_toc = True
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
if in_toc:
|
| 169 |
+
if re.match(r"^.{3,80}\s*\.{3,}\s*\d+\s*$", line.strip()):
|
| 170 |
+
continue
|
| 171 |
+
stripped = line.strip()
|
| 172 |
+
if stripped and not re.search(r"\.{3,}", stripped):
|
| 173 |
+
sec = detect_section(stripped)
|
| 174 |
+
if not sec:
|
| 175 |
+
in_toc = False
|
| 176 |
+
|
| 177 |
+
if in_toc:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
if is_sidebar_start(line):
|
| 181 |
+
in_sidebar = True
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
if in_sidebar:
|
| 185 |
+
stripped = line.strip()
|
| 186 |
+
if stripped.startswith("•") or stripped.startswith("–") or not stripped:
|
| 187 |
+
continue
|
| 188 |
+
sec = detect_section(stripped)
|
| 189 |
+
if sec or (stripped and not stripped.startswith("•")):
|
| 190 |
+
in_sidebar = False
|
| 191 |
+
if sec:
|
| 192 |
+
cleaned.append(line)
|
| 193 |
+
continue
|
| 194 |
+
cleaned.append(line)
|
| 195 |
+
continue
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
cleaned.append(line)
|
| 199 |
+
|
| 200 |
+
return "\n".join(cleaned)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def extract_pages_pdf(pdf_path):
|
| 204 |
+
pages = []
|
| 205 |
+
try:
|
| 206 |
+
with pdfplumber.open(pdf_path) as pdf:
|
| 207 |
+
for page_num, page in enumerate(pdf.pages, start=1):
|
| 208 |
+
text = page.extract_text()
|
| 209 |
+
if text:
|
| 210 |
+
pages.append((page_num, text))
|
| 211 |
+
|
| 212 |
+
tables = page.extract_tables()
|
| 213 |
+
if tables:
|
| 214 |
+
for table in tables:
|
| 215 |
+
table_text = format_table(table)
|
| 216 |
+
if table_text:
|
| 217 |
+
pages.append((page_num, f"[TABLE]\n{table_text}\n[/TABLE]"))
|
| 218 |
+
except Exception as e:
|
| 219 |
+
print(f"Error extracting {pdf_path}: {e}")
|
| 220 |
+
return pages
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def format_table(table):
|
| 224 |
+
if not table or len(table) < 2:
|
| 225 |
+
return ""
|
| 226 |
+
rows = []
|
| 227 |
+
for row in table:
|
| 228 |
+
if row:
|
| 229 |
+
cells = [str(cell).strip() if cell else "" for cell in row]
|
| 230 |
+
if any(cells):
|
| 231 |
+
rows.append(" | ".join(cells))
|
| 232 |
+
return "\n".join(rows)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def build_paragraphs(text):
|
| 236 |
+
lines = text.split("\n")
|
| 237 |
+
paragraphs = []
|
| 238 |
+
current = []
|
| 239 |
+
|
| 240 |
+
for line in lines:
|
| 241 |
+
stripped = line.strip()
|
| 242 |
+
if not stripped:
|
| 243 |
+
if current:
|
| 244 |
+
paragraphs.append(" ".join(current))
|
| 245 |
+
current = []
|
| 246 |
+
continue
|
| 247 |
+
|
| 248 |
+
is_bullet = bool(re.match(r"^[•\-–▪o]\s", stripped))
|
| 249 |
+
is_numbered = bool(re.match(r"^\d+[\.\)]\s", stripped))
|
| 250 |
+
is_lettered = bool(re.match(r"^[a-z][\.\)]\s", stripped))
|
| 251 |
+
is_list_item = is_bullet or is_numbered or is_lettered
|
| 252 |
+
|
| 253 |
+
if is_list_item:
|
| 254 |
+
if current:
|
| 255 |
+
paragraphs.append(" ".join(current))
|
| 256 |
+
current = []
|
| 257 |
+
current.append(stripped)
|
| 258 |
+
elif stripped.startswith("o\t") or stripped.startswith("o "):
|
| 259 |
+
if current:
|
| 260 |
+
paragraphs.append(" ".join(current))
|
| 261 |
+
current = []
|
| 262 |
+
current.append(stripped)
|
| 263 |
+
else:
|
| 264 |
+
current.append(stripped)
|
| 265 |
+
|
| 266 |
+
if current:
|
| 267 |
+
paragraphs.append(" ".join(current))
|
| 268 |
+
|
| 269 |
+
return paragraphs
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def segment_into_sections(pages):
|
| 273 |
+
all_text_by_section = []
|
| 274 |
+
current_section = ""
|
| 275 |
+
current_content = []
|
| 276 |
+
current_page_start = 1
|
| 277 |
+
|
| 278 |
+
for page_num, raw_text in pages:
|
| 279 |
+
cleaned = clean_page_text(raw_text)
|
| 280 |
+
lines = cleaned.split("\n")
|
| 281 |
+
|
| 282 |
+
for line in lines:
|
| 283 |
+
stripped = line.strip()
|
| 284 |
+
if not stripped:
|
| 285 |
+
current_content.append("")
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
sec = detect_section(stripped)
|
| 289 |
+
if sec:
|
| 290 |
+
if current_content:
|
| 291 |
+
text = "\n".join(current_content).strip()
|
| 292 |
+
if text and current_section:
|
| 293 |
+
all_text_by_section.append(PolicySection(
|
| 294 |
+
section=current_section,
|
| 295 |
+
content=text,
|
| 296 |
+
page_start=current_page_start,
|
| 297 |
+
page_end=page_num
|
| 298 |
+
))
|
| 299 |
+
current_section = sec
|
| 300 |
+
current_content = []
|
| 301 |
+
current_page_start = page_num
|
| 302 |
+
continue
|
| 303 |
+
|
| 304 |
+
current_content.append(stripped)
|
| 305 |
+
|
| 306 |
+
if current_content and current_section:
|
| 307 |
+
text = "\n".join(current_content).strip()
|
| 308 |
+
if text:
|
| 309 |
+
last_page = pages[-1][0] if pages else 1
|
| 310 |
+
all_text_by_section.append(PolicySection(
|
| 311 |
+
section=current_section,
|
| 312 |
+
content=text,
|
| 313 |
+
page_start=current_page_start,
|
| 314 |
+
page_end=last_page
|
| 315 |
+
))
|
| 316 |
+
|
| 317 |
+
return all_text_by_section
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def extract_html(path, filename):
|
| 321 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
| 322 |
+
soup = BeautifulSoup(f, "html.parser")
|
| 323 |
+
text = soup.get_text("\n")
|
| 324 |
+
|
| 325 |
+
policy_name, policy_number, effective_date, plan_type, doc_type = extract_metadata(text, filename)
|
| 326 |
+
|
| 327 |
+
pages = [(1, text)]
|
| 328 |
+
sections = segment_into_sections(pages)
|
| 329 |
+
|
| 330 |
+
return PolicyDocument(
|
| 331 |
+
filename=filename,
|
| 332 |
+
policy_name=policy_name,
|
| 333 |
+
policy_number=policy_number,
|
| 334 |
+
effective_date=effective_date,
|
| 335 |
+
plan_type=plan_type,
|
| 336 |
+
doc_type=doc_type,
|
| 337 |
+
sections=[asdict(s) for s in sections if s.section not in SKIP_SECTIONS]
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def extract_policy(pdf_path, filename):
|
| 342 |
+
pages = extract_pages_pdf(pdf_path)
|
| 343 |
+
|
| 344 |
+
if not pages:
|
| 345 |
+
return None
|
| 346 |
+
|
| 347 |
+
full_text = "\n".join(text for _, text in pages[:3])
|
| 348 |
+
policy_name, policy_number, effective_date, plan_type, doc_type = extract_metadata(full_text, filename)
|
| 349 |
+
|
| 350 |
+
sections = segment_into_sections(pages)
|
| 351 |
+
|
| 352 |
+
filtered_sections = []
|
| 353 |
+
for sec in sections:
|
| 354 |
+
if sec.section in SKIP_SECTIONS:
|
| 355 |
+
continue
|
| 356 |
+
|
| 357 |
+
paragraphs = build_paragraphs(sec.content)
|
| 358 |
+
cleaned_content = "\n\n".join(p for p in paragraphs if len(p.strip()) > 10)
|
| 359 |
+
|
| 360 |
+
if cleaned_content.strip():
|
| 361 |
+
sec.content = cleaned_content
|
| 362 |
+
filtered_sections.append(sec)
|
| 363 |
+
|
| 364 |
+
return PolicyDocument(
|
| 365 |
+
filename=filename,
|
| 366 |
+
policy_name=policy_name,
|
| 367 |
+
policy_number=policy_number,
|
| 368 |
+
effective_date=effective_date,
|
| 369 |
+
plan_type=plan_type,
|
| 370 |
+
doc_type=doc_type,
|
| 371 |
+
sections=[asdict(s) for s in filtered_sections]
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def main():
|
| 376 |
+
all_policies = []
|
| 377 |
+
|
| 378 |
+
pdfs = [f for f in os.listdir(PDF_DIR) if f not in SKIP_FILES]
|
| 379 |
+
pdfs.sort()
|
| 380 |
+
|
| 381 |
+
for filename in tqdm(pdfs, desc="Extracting policies"):
|
| 382 |
+
path = os.path.join(PDF_DIR, filename)
|
| 383 |
+
|
| 384 |
+
if is_html_file(path):
|
| 385 |
+
doc = extract_html(path, filename)
|
| 386 |
+
else:
|
| 387 |
+
doc = extract_policy(path, filename)
|
| 388 |
+
|
| 389 |
+
if doc and doc.sections:
|
| 390 |
+
all_policies.append(asdict(doc))
|
| 391 |
+
|
| 392 |
+
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
|
| 393 |
+
|
| 394 |
+
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
| 395 |
+
json.dump(all_policies, f, indent=2, ensure_ascii=False)
|
| 396 |
+
|
| 397 |
+
total_sections = sum(len(p["sections"]) for p in all_policies)
|
| 398 |
+
print(f"Extracted {len(all_policies)} policies with {total_sections} sections")
|
| 399 |
+
print(f"Saved to: {OUTPUT_FILE}")
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
if __name__ == "__main__":
|
| 403 |
+
main()
|