Mayank Patel commited on
Commit
5c32ed1
·
1 Parent(s): 5366a94

Initial deployment: UHC Medical Policy Chatbot

Browse files

RAG 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 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: Uhc Policy Chatbot
3
- emoji: 🏆
4
- colorFrom: green
5
  colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.9.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()