added
Browse files- .dockerignore +11 -0
- .env.example +5 -0
- Dockerfile +36 -0
- app/__init__.py +1 -0
- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/main.py +112 -0
- app/services/__init__.py +1 -0
- app/services/__pycache__/__init__.cpython-313.pyc +0 -0
- app/services/__pycache__/cache.cpython-313.pyc +0 -0
- app/services/__pycache__/claims.cpython-313.pyc +0 -0
- app/services/__pycache__/llm.cpython-313.pyc +0 -0
- app/services/__pycache__/ocr.cpython-313.pyc +0 -0
- app/services/__pycache__/rag.cpython-313.pyc +0 -0
- app/services/__pycache__/schemas.cpython-313.pyc +0 -0
- app/services/cache.py +131 -0
- app/services/claims.py +365 -0
- app/services/llm.py +379 -0
- app/services/ocr.py +208 -0
- app/services/rag.py +306 -0
- app/services/schemas.py +58 -0
- frontend/app.js +169 -0
- frontend/index.html +133 -0
- frontend/styles.css +301 -0
- scripts/create_bangladesh_invoice.py +76 -0
- scripts/create_complete_claim_pdf.py +198 -0
- scripts/create_sample_invoice.py +65 -0
.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.pytest_cache
|
| 5 |
+
__pycache__
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
insurance
|
| 10 |
+
sample_invoice_*.pdf
|
| 11 |
+
complete_claim_package_*.pdf
|
.env.example
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CLAIMS_LLM_PROVIDER=groq
|
| 2 |
+
CLAIMS_LLM_MODEL=llama-3.3-70b-versatile
|
| 3 |
+
GROQ_API_KEY=replace_with_your_groq_api_key
|
| 4 |
+
REDIS_URL=redis://localhost:6379/0
|
| 5 |
+
SELF_RAG_CONFIDENCE_THRESHOLD=0.75
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
HF_HOME=/app/.cache/huggingface \
|
| 7 |
+
TRANSFORMERS_CACHE=/app/.cache/huggingface
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Tesseract is used when a PDF/image does not contain extractable text.
|
| 12 |
+
# libgomp1 is needed by common ML wheels used by the retrieval stack.
|
| 13 |
+
RUN apt-get update \
|
| 14 |
+
&& apt-get install -y --no-install-recommends \
|
| 15 |
+
libgomp1 \
|
| 16 |
+
tesseract-ocr \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
COPY requirements.txt ./
|
| 20 |
+
RUN python -m pip install --upgrade pip \
|
| 21 |
+
&& python -m pip install -r requirements.txt
|
| 22 |
+
|
| 23 |
+
COPY app ./app
|
| 24 |
+
COPY frontend ./frontend
|
| 25 |
+
COPY data ./data
|
| 26 |
+
COPY README.md .env.example ./
|
| 27 |
+
|
| 28 |
+
RUN useradd --create-home --shell /usr/sbin/nologin claims \
|
| 29 |
+
&& mkdir -p /app/.cache/huggingface \
|
| 30 |
+
&& chown -R claims:claims /app
|
| 31 |
+
|
| 32 |
+
USER claims
|
| 33 |
+
|
| 34 |
+
EXPOSE 8000
|
| 35 |
+
|
| 36 |
+
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (112 Bytes). View file
|
|
|
app/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (5.44 kB). View file
|
|
|
app/main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Annotated
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import FileResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
|
| 13 |
+
from app.services.claims import ClaimDecisionEngine
|
| 14 |
+
from app.services.cache import LRUCache
|
| 15 |
+
from app.services.llm import LLMUnavailableError
|
| 16 |
+
from app.services.ocr import InvoiceExtractor
|
| 17 |
+
from app.services.schemas import ClaimInput
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 21 |
+
load_dotenv(BASE_DIR / ".env")
|
| 22 |
+
|
| 23 |
+
STATIC_DIR = BASE_DIR / "frontend"
|
| 24 |
+
POLICY_PATH = BASE_DIR / "data" / "policies" / "bupa.pdf"
|
| 25 |
+
|
| 26 |
+
app = FastAPI(title="AI Claims Processing System", version="1.0.0")
|
| 27 |
+
app.add_middleware(
|
| 28 |
+
CORSMiddleware,
|
| 29 |
+
allow_origins=["*"],
|
| 30 |
+
allow_credentials=True,
|
| 31 |
+
allow_methods=["*"],
|
| 32 |
+
allow_headers=["*"],
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 36 |
+
|
| 37 |
+
invoice_extractor = InvoiceExtractor()
|
| 38 |
+
invoice_fields_cache = LRUCache(max_size=128)
|
| 39 |
+
decision_engine: ClaimDecisionEngine | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_decision_engine() -> ClaimDecisionEngine:
|
| 43 |
+
global decision_engine
|
| 44 |
+
if decision_engine is None:
|
| 45 |
+
try:
|
| 46 |
+
decision_engine = ClaimDecisionEngine(policy_path=POLICY_PATH)
|
| 47 |
+
except LLMUnavailableError as exc:
|
| 48 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 49 |
+
return decision_engine
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@app.get("/")
|
| 53 |
+
def index() -> FileResponse:
|
| 54 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.get("/api/health")
|
| 58 |
+
def health() -> dict[str, str]:
|
| 59 |
+
return {"status": "ok"}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.post("/api/extract-invoice")
|
| 63 |
+
async def extract_invoice(invoice: Annotated[UploadFile, File(...)]) -> dict:
|
| 64 |
+
content = await invoice.read()
|
| 65 |
+
if not content:
|
| 66 |
+
raise HTTPException(status_code=400, detail="Invoice file is empty")
|
| 67 |
+
|
| 68 |
+
file_hash = hashlib.sha256(content).hexdigest()
|
| 69 |
+
extracted = invoice_extractor.extract(content, invoice.filename or "invoice.pdf")
|
| 70 |
+
invoice_fields_cache.set(file_hash, extracted)
|
| 71 |
+
return {"invoice_hash": file_hash, "fields": extracted.model_dump()}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@app.post("/api/process-claim")
|
| 75 |
+
async def process_claim(
|
| 76 |
+
patient_name: Annotated[str, Form()],
|
| 77 |
+
patient_address: Annotated[str, Form()],
|
| 78 |
+
date_of_treatment: Annotated[str, Form()],
|
| 79 |
+
medical_facility: Annotated[str, Form()],
|
| 80 |
+
claim_reason: Annotated[str, Form()],
|
| 81 |
+
claim_amount: Annotated[float, Form()],
|
| 82 |
+
claim_item: Annotated[str, Form()] = "General Practitioner",
|
| 83 |
+
invoice_hash: Annotated[str | None, Form()] = None,
|
| 84 |
+
invoice: Annotated[UploadFile | None, File()] = None,
|
| 85 |
+
) -> dict:
|
| 86 |
+
upload_hash = invoice_hash
|
| 87 |
+
extracted_fields = None
|
| 88 |
+
|
| 89 |
+
if invoice is not None:
|
| 90 |
+
content = await invoice.read()
|
| 91 |
+
if content:
|
| 92 |
+
upload_hash = hashlib.sha256(content).hexdigest()
|
| 93 |
+
extracted_fields = invoice_fields_cache.get(upload_hash)
|
| 94 |
+
if extracted_fields is None:
|
| 95 |
+
extracted_fields = invoice_extractor.extract(content, invoice.filename or "invoice.pdf")
|
| 96 |
+
invoice_fields_cache.set(upload_hash, extracted_fields)
|
| 97 |
+
elif upload_hash:
|
| 98 |
+
extracted_fields = invoice_fields_cache.get(upload_hash)
|
| 99 |
+
|
| 100 |
+
claim = ClaimInput(
|
| 101 |
+
patient_name=patient_name.strip(),
|
| 102 |
+
patient_address=patient_address.strip(),
|
| 103 |
+
claim_item=claim_item.strip(),
|
| 104 |
+
date_of_treatment=date_of_treatment.strip(),
|
| 105 |
+
medical_facility=medical_facility.strip(),
|
| 106 |
+
claim_reason=claim_reason.strip(),
|
| 107 |
+
claim_amount=claim_amount,
|
| 108 |
+
invoice_hash=upload_hash,
|
| 109 |
+
extracted_invoice=extracted_fields,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return get_decision_engine().decide(claim).model_dump()
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/services/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (121 Bytes). View file
|
|
|
app/services/__pycache__/cache.cpython-313.pyc
ADDED
|
Binary file (7.44 kB). View file
|
|
|
app/services/__pycache__/claims.cpython-313.pyc
ADDED
|
Binary file (20.9 kB). View file
|
|
|
app/services/__pycache__/llm.cpython-313.pyc
ADDED
|
Binary file (22 kB). View file
|
|
|
app/services/__pycache__/ocr.cpython-313.pyc
ADDED
|
Binary file (12 kB). View file
|
|
|
app/services/__pycache__/rag.cpython-313.pyc
ADDED
|
Binary file (18.2 kB). View file
|
|
|
app/services/__pycache__/schemas.cpython-313.pyc
ADDED
|
Binary file (2.7 kB). View file
|
|
|
app/services/cache.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from collections import OrderedDict
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def stable_hash(payload: Any) -> str:
|
| 14 |
+
serialized = json.dumps(payload, sort_keys=True, default=str)
|
| 15 |
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class LRUCache:
|
| 19 |
+
def __init__(self, max_size: int = 256) -> None:
|
| 20 |
+
self.max_size = max_size
|
| 21 |
+
self._items: OrderedDict[str, Any] = OrderedDict()
|
| 22 |
+
|
| 23 |
+
def get(self, key: str) -> Any | None:
|
| 24 |
+
if key not in self._items:
|
| 25 |
+
return None
|
| 26 |
+
self._items.move_to_end(key)
|
| 27 |
+
return self._items[key]
|
| 28 |
+
|
| 29 |
+
def set(self, key: str, value: Any) -> None:
|
| 30 |
+
self._items[key] = value
|
| 31 |
+
self._items.move_to_end(key)
|
| 32 |
+
if len(self._items) > self.max_size:
|
| 33 |
+
self._items.popitem(last=False)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class SemanticCacheEntry:
|
| 38 |
+
vector: list[float]
|
| 39 |
+
value: Any
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class SemanticCache:
|
| 43 |
+
def __init__(self, threshold: float = 0.92, max_size: int = 128) -> None:
|
| 44 |
+
self.threshold = threshold
|
| 45 |
+
self.max_size = max_size
|
| 46 |
+
self._items: list[SemanticCacheEntry] = []
|
| 47 |
+
|
| 48 |
+
def get(self, vector: list[float]) -> Any | None:
|
| 49 |
+
if not self._items:
|
| 50 |
+
return None
|
| 51 |
+
query = np.array(vector, dtype=np.float32)
|
| 52 |
+
query_norm = np.linalg.norm(query)
|
| 53 |
+
if query_norm == 0:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
best_score = -1.0
|
| 57 |
+
best_value = None
|
| 58 |
+
for entry in self._items:
|
| 59 |
+
candidate = np.array(entry.vector, dtype=np.float32)
|
| 60 |
+
denom = query_norm * np.linalg.norm(candidate)
|
| 61 |
+
score = float(np.dot(query, candidate) / denom) if denom else 0.0
|
| 62 |
+
if score > best_score:
|
| 63 |
+
best_score = score
|
| 64 |
+
best_value = entry.value
|
| 65 |
+
|
| 66 |
+
return best_value if best_score >= self.threshold else None
|
| 67 |
+
|
| 68 |
+
def set(self, vector: list[float], value: Any) -> None:
|
| 69 |
+
self._items.append(SemanticCacheEntry(vector=vector, value=value))
|
| 70 |
+
if len(self._items) > self.max_size:
|
| 71 |
+
self._items.pop(0)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class RedisSemanticCache:
|
| 75 |
+
def __init__(self, namespace: str, threshold: float = 0.92, max_size: int = 256) -> None:
|
| 76 |
+
self.namespace = namespace
|
| 77 |
+
self.threshold = threshold
|
| 78 |
+
self.max_size = max_size
|
| 79 |
+
self.enabled = False
|
| 80 |
+
self._redis = None
|
| 81 |
+
|
| 82 |
+
redis_url = os.getenv("REDIS_URL")
|
| 83 |
+
if not redis_url:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
import redis
|
| 88 |
+
|
| 89 |
+
self._redis = redis.Redis.from_url(redis_url, decode_responses=True)
|
| 90 |
+
self._redis.ping()
|
| 91 |
+
self.enabled = True
|
| 92 |
+
except Exception:
|
| 93 |
+
self._redis = None
|
| 94 |
+
self.enabled = False
|
| 95 |
+
|
| 96 |
+
def get(self, vector: list[float]) -> Any | None:
|
| 97 |
+
if not self.enabled or self._redis is None:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
query = np.array(vector, dtype=np.float32)
|
| 101 |
+
query_norm = np.linalg.norm(query)
|
| 102 |
+
if query_norm == 0:
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
best_score = -1.0
|
| 106 |
+
best_value = None
|
| 107 |
+
index_key = f"{self.namespace}:index"
|
| 108 |
+
for item_key in self._redis.lrange(index_key, 0, -1):
|
| 109 |
+
raw = self._redis.get(item_key)
|
| 110 |
+
if not raw:
|
| 111 |
+
continue
|
| 112 |
+
item = json.loads(raw)
|
| 113 |
+
candidate = np.array(item["vector"], dtype=np.float32)
|
| 114 |
+
denom = query_norm * np.linalg.norm(candidate)
|
| 115 |
+
score = float(np.dot(query, candidate) / denom) if denom else 0.0
|
| 116 |
+
if score > best_score:
|
| 117 |
+
best_score = score
|
| 118 |
+
best_value = item["value"]
|
| 119 |
+
|
| 120 |
+
return best_value if best_score >= self.threshold else None
|
| 121 |
+
|
| 122 |
+
def set(self, vector: list[float], value: Any) -> None:
|
| 123 |
+
if not self.enabled or self._redis is None:
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
payload = {"vector": vector, "value": value}
|
| 127 |
+
item_key = f"{self.namespace}:item:{stable_hash(payload)}"
|
| 128 |
+
index_key = f"{self.namespace}:index"
|
| 129 |
+
self._redis.set(item_key, json.dumps(payload, default=str))
|
| 130 |
+
self._redis.lpush(index_key, item_key)
|
| 131 |
+
self._redis.ltrim(index_key, 0, self.max_size - 1)
|
app/services/claims.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
from datetime import date, datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from app.services.cache import LRUCache, RedisSemanticCache, SemanticCache, stable_hash
|
| 9 |
+
from app.services.llm import ClaimLLMAgent
|
| 10 |
+
from app.services.rag import PolicyRAG
|
| 11 |
+
from app.services.schemas import Citation, ClaimDecision, ClaimInput
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CACHE_SCHEMA_VERSION = "claims-pipeline-v19-high-confidence-fast-path"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ClaimDecisionEngine:
|
| 18 |
+
def __init__(self, policy_path: Path) -> None:
|
| 19 |
+
self.rag = PolicyRAG(policy_path)
|
| 20 |
+
self.agent = ClaimLLMAgent()
|
| 21 |
+
self.exact_cache = LRUCache(max_size=256)
|
| 22 |
+
self.semantic_cache = SemanticCache(threshold=0.94, max_size=128)
|
| 23 |
+
self.redis_semantic_cache = RedisSemanticCache(
|
| 24 |
+
namespace=f"claims:{CACHE_SCHEMA_VERSION}:{stable_hash(self.rag.policy_version)}",
|
| 25 |
+
threshold=0.94,
|
| 26 |
+
max_size=256,
|
| 27 |
+
)
|
| 28 |
+
self.self_rag_confidence_threshold = self._confidence_threshold()
|
| 29 |
+
|
| 30 |
+
def decide(self, claim: ClaimInput) -> ClaimDecision:
|
| 31 |
+
pipeline_trace = {
|
| 32 |
+
"stage_1_ingestion_ocr": "completed before adjudication endpoint",
|
| 33 |
+
"stage_2_prefill_form": "completed in browser before submission",
|
| 34 |
+
"policy_version": self.rag.policy_version,
|
| 35 |
+
"cache": {"exact": "miss", "semantic_memory": "miss", "semantic_redis": "disabled"},
|
| 36 |
+
}
|
| 37 |
+
cache_payload = {
|
| 38 |
+
"cache_schema_version": CACHE_SCHEMA_VERSION,
|
| 39 |
+
"policy_version": self.rag.policy_version,
|
| 40 |
+
"claim": claim.model_dump(exclude={"extracted_invoice"}),
|
| 41 |
+
}
|
| 42 |
+
exact_key = stable_hash(cache_payload)
|
| 43 |
+
exact = self.exact_cache.get(exact_key)
|
| 44 |
+
if exact:
|
| 45 |
+
exact.cache_hit = True
|
| 46 |
+
exact.pipeline_trace["cache"] = {"exact": "hit"}
|
| 47 |
+
return exact
|
| 48 |
+
|
| 49 |
+
semantic_vector = self.rag.embed_query(self._claim_query(claim))
|
| 50 |
+
semantic_guard = self._semantic_guard(claim)
|
| 51 |
+
redis_semantic = self.redis_semantic_cache.get(semantic_vector)
|
| 52 |
+
if redis_semantic and redis_semantic.get("guard") == semantic_guard:
|
| 53 |
+
decision = ClaimDecision.model_validate(redis_semantic["decision"])
|
| 54 |
+
decision.cache_hit = True
|
| 55 |
+
decision.pipeline_trace["cache"] = {"semantic_redis": "hit"}
|
| 56 |
+
return decision
|
| 57 |
+
|
| 58 |
+
semantic = self.semantic_cache.get(semantic_vector)
|
| 59 |
+
if semantic and semantic.get("guard") == semantic_guard:
|
| 60 |
+
decision = semantic["decision"]
|
| 61 |
+
decision.cache_hit = True
|
| 62 |
+
decision.pipeline_trace["cache"] = {"semantic_memory": "hit"}
|
| 63 |
+
return decision
|
| 64 |
+
pipeline_trace["cache"]["semantic_redis"] = "miss" if self.redis_semantic_cache.enabled else "disabled"
|
| 65 |
+
|
| 66 |
+
retrieval_plan = self.agent.plan_retrieval(claim)
|
| 67 |
+
queries = self._build_retrieval_queries(retrieval_plan)
|
| 68 |
+
pipeline_trace["stage_4_query_rewriting"] = {
|
| 69 |
+
"route": retrieval_plan.route,
|
| 70 |
+
"hyde": bool(retrieval_plan.hyde_document),
|
| 71 |
+
"step_back_question": retrieval_plan.step_back_question,
|
| 72 |
+
"multi_query_count": len(retrieval_plan.rewritten_queries[:8]),
|
| 73 |
+
"metadata_filters": retrieval_plan.metadata_filters[:6],
|
| 74 |
+
"query_count": len(queries),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
docs = self.rag.retrieve(queries)
|
| 78 |
+
pipeline_trace["stage_5_hybrid_retrieval"] = self.rag.last_retrieval_trace
|
| 79 |
+
pipeline_trace["stage_6_cross_encoder_reranking"] = {
|
| 80 |
+
"reranker": "FlashRank ms-marco-MiniLM-L-12-v2",
|
| 81 |
+
"top_k": min(len(docs), 12),
|
| 82 |
+
}
|
| 83 |
+
flags = self._validate_claim(claim)
|
| 84 |
+
flags.extend(retrieval_plan.document_checks[:8])
|
| 85 |
+
|
| 86 |
+
citations = self._docs_to_citations(docs[:12])
|
| 87 |
+
decision_draft = self.agent.decide(claim, citations, flags)
|
| 88 |
+
self_rag_trace = {
|
| 89 |
+
"threshold": self.self_rag_confidence_threshold,
|
| 90 |
+
"initial_confidence": decision_draft.confidence,
|
| 91 |
+
"mode": "skipped",
|
| 92 |
+
"iterations": [],
|
| 93 |
+
}
|
| 94 |
+
if decision_draft.confidence < self.self_rag_confidence_threshold:
|
| 95 |
+
self_rag_trace["mode"] = "triggered"
|
| 96 |
+
citations_changed = False
|
| 97 |
+
for iteration in range(1, 4):
|
| 98 |
+
evidence_grade = self.agent.grade_evidence(claim, citations)
|
| 99 |
+
evidence_sufficient = self._as_bool(evidence_grade.sufficient)
|
| 100 |
+
self_rag_trace["iterations"].append(
|
| 101 |
+
{
|
| 102 |
+
"iteration": iteration,
|
| 103 |
+
"sufficient": evidence_sufficient,
|
| 104 |
+
"relevance_check": evidence_grade.relevance_check,
|
| 105 |
+
"grounding_check": evidence_grade.grounding_check,
|
| 106 |
+
"hallucination_risk": evidence_grade.hallucination_risk,
|
| 107 |
+
"contradiction_check": evidence_grade.contradiction_check,
|
| 108 |
+
"missing_questions": evidence_grade.missing_questions[:5],
|
| 109 |
+
"relevant_citation_indexes": evidence_grade.relevant_citation_indexes[:12],
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
if evidence_sufficient or not evidence_grade.missing_questions:
|
| 113 |
+
break
|
| 114 |
+
|
| 115 |
+
follow_up_docs = self.rag.retrieve(evidence_grade.missing_questions[:5])
|
| 116 |
+
follow_up_citations = self._docs_to_citations(follow_up_docs[:6])
|
| 117 |
+
updated_citations = self._dedupe_citations(citations + follow_up_citations)
|
| 118 |
+
citations_changed = citations_changed or len(updated_citations) > len(citations)
|
| 119 |
+
citations = updated_citations
|
| 120 |
+
|
| 121 |
+
if citations_changed:
|
| 122 |
+
decision_draft = self.agent.decide(claim, citations, flags)
|
| 123 |
+
self_rag_trace["redraft_confidence"] = decision_draft.confidence
|
| 124 |
+
|
| 125 |
+
pipeline_trace["stage_7_self_rag_loop"] = self_rag_trace
|
| 126 |
+
|
| 127 |
+
selected_citations = self._select_citations(citations, decision_draft.citation_indexes)
|
| 128 |
+
verification_citations = self._dedupe_citations(selected_citations + citations)[:12]
|
| 129 |
+
selected_citations = self._select_report_citations(selected_citations, verification_citations)
|
| 130 |
+
final_status = decision_draft.status
|
| 131 |
+
final_conclusion = decision_draft.conclusion
|
| 132 |
+
final_flags = self._dedupe_strings(flags + decision_draft.flags)
|
| 133 |
+
report = decision_draft
|
| 134 |
+
|
| 135 |
+
if self_rag_trace["mode"] == "skipped":
|
| 136 |
+
pipeline_trace["stage_7_final_grounding_verifier"] = "skipped for high-confidence fast path"
|
| 137 |
+
pipeline_trace["stage_8_verified_report_writer"] = "skipped; initial decision report reused"
|
| 138 |
+
else:
|
| 139 |
+
final_verification = self.agent.verify_decision(claim, verification_citations, decision_draft)
|
| 140 |
+
pipeline_trace["stage_7_final_grounding_verifier"] = final_verification.model_dump()
|
| 141 |
+
final_flags = self._dedupe_strings(final_flags + final_verification.verifier_notes)
|
| 142 |
+
verifier_grounded = self._as_bool(final_verification.grounded)
|
| 143 |
+
verifier_blocked = (
|
| 144 |
+
not verifier_grounded
|
| 145 |
+
or final_verification.hallucination_risk == "high"
|
| 146 |
+
or self._as_bool(final_verification.contradiction_found)
|
| 147 |
+
)
|
| 148 |
+
if verifier_blocked:
|
| 149 |
+
final_status = final_verification.corrected_status
|
| 150 |
+
final_conclusion = final_verification.corrected_conclusion
|
| 151 |
+
elif final_verification.corrected_status != decision_draft.status:
|
| 152 |
+
final_status = final_verification.corrected_status
|
| 153 |
+
final_conclusion = self._report_conclusion_from_verifier(
|
| 154 |
+
claim=claim,
|
| 155 |
+
corrected_status=final_verification.corrected_status,
|
| 156 |
+
verifier_conclusion=final_verification.corrected_conclusion,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
report = self.agent.write_report(
|
| 160 |
+
claim=claim,
|
| 161 |
+
citations=verification_citations,
|
| 162 |
+
status=final_status,
|
| 163 |
+
)
|
| 164 |
+
pipeline_trace["stage_8_verified_report_writer"] = "completed"
|
| 165 |
+
|
| 166 |
+
decision = ClaimDecision(
|
| 167 |
+
status=final_status,
|
| 168 |
+
confidence=decision_draft.confidence,
|
| 169 |
+
patient_name=claim.patient_name,
|
| 170 |
+
patient_address=claim.patient_address,
|
| 171 |
+
claim_item=claim.claim_item,
|
| 172 |
+
medical_facility=claim.medical_facility,
|
| 173 |
+
date_of_treatment=claim.date_of_treatment,
|
| 174 |
+
total_claim_amount=claim.claim_amount,
|
| 175 |
+
executive_summary=report.executive_summary,
|
| 176 |
+
introduction=report.introduction,
|
| 177 |
+
claim_description=(
|
| 178 |
+
f"{claim.patient_name} visited {claim.medical_facility} for {claim.claim_reason} "
|
| 179 |
+
f"and the total claim amount is {claim.claim_amount:g}."
|
| 180 |
+
),
|
| 181 |
+
document_verification=report.document_verification,
|
| 182 |
+
document_summary=report.document_summary,
|
| 183 |
+
conclusion=report.conclusion,
|
| 184 |
+
reason_codes=decision_draft.reason_codes,
|
| 185 |
+
flags=final_flags,
|
| 186 |
+
citations=selected_citations,
|
| 187 |
+
pipeline_trace=pipeline_trace,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
self.exact_cache.set(exact_key, decision)
|
| 191 |
+
self.semantic_cache.set(semantic_vector, {"guard": semantic_guard, "decision": decision})
|
| 192 |
+
self.redis_semantic_cache.set(
|
| 193 |
+
semantic_vector,
|
| 194 |
+
{"guard": semantic_guard, "decision": decision.model_dump()},
|
| 195 |
+
)
|
| 196 |
+
return decision
|
| 197 |
+
|
| 198 |
+
def _claim_query(self, claim: ClaimInput) -> str:
|
| 199 |
+
return " ".join(
|
| 200 |
+
[
|
| 201 |
+
claim.patient_address,
|
| 202 |
+
claim.claim_item,
|
| 203 |
+
claim.date_of_treatment,
|
| 204 |
+
claim.medical_facility,
|
| 205 |
+
claim.claim_reason,
|
| 206 |
+
str(claim.claim_amount),
|
| 207 |
+
]
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def _semantic_guard(self, claim: ClaimInput) -> str:
|
| 211 |
+
guard_payload = {
|
| 212 |
+
"patient_name": claim.patient_name.strip().lower(),
|
| 213 |
+
"date_of_treatment": claim.date_of_treatment.strip().lower(),
|
| 214 |
+
"medical_facility": claim.medical_facility.strip().lower(),
|
| 215 |
+
"claim_amount": round(float(claim.claim_amount), 2),
|
| 216 |
+
"policy_version": self.rag.policy_version,
|
| 217 |
+
}
|
| 218 |
+
return stable_hash(guard_payload)
|
| 219 |
+
|
| 220 |
+
def _confidence_threshold(self) -> float:
|
| 221 |
+
try:
|
| 222 |
+
threshold = float(os.getenv("SELF_RAG_CONFIDENCE_THRESHOLD", "0.75"))
|
| 223 |
+
except ValueError:
|
| 224 |
+
return 0.75
|
| 225 |
+
return min(max(threshold, 0.0), 1.0)
|
| 226 |
+
|
| 227 |
+
def _validate_claim(self, claim: ClaimInput) -> list[str]:
|
| 228 |
+
flags: list[str] = []
|
| 229 |
+
parsed_date = self._parse_date(claim.date_of_treatment)
|
| 230 |
+
|
| 231 |
+
if parsed_date is None:
|
| 232 |
+
flags.append("Treatment date could not be parsed.")
|
| 233 |
+
elif parsed_date > date.today():
|
| 234 |
+
flags.append("Treatment date is in the future.")
|
| 235 |
+
elif parsed_date.year < 1900:
|
| 236 |
+
flags.append("Treatment date is not realistic.")
|
| 237 |
+
|
| 238 |
+
if not claim.patient_name:
|
| 239 |
+
flags.append("Patient name is missing.")
|
| 240 |
+
if not claim.medical_facility:
|
| 241 |
+
flags.append("Medical facility is missing.")
|
| 242 |
+
if claim.claim_amount <= 0:
|
| 243 |
+
flags.append("Claim amount must be greater than zero.")
|
| 244 |
+
if not claim.patient_address:
|
| 245 |
+
flags.append("Patient address is missing.")
|
| 246 |
+
elif not self._looks_like_uk_address(claim.patient_address):
|
| 247 |
+
flags.append("Patient address is not clearly within the UK.")
|
| 248 |
+
|
| 249 |
+
extracted = claim.extracted_invoice
|
| 250 |
+
if extracted:
|
| 251 |
+
dob = self._parse_date(extracted.date_of_birth or "")
|
| 252 |
+
if dob and dob.year < 1900:
|
| 253 |
+
flags.append("Invoice date of birth is not realistic.")
|
| 254 |
+
if extracted.amount_payable is not None and abs(extracted.amount_payable - claim.claim_amount) > 1:
|
| 255 |
+
flags.append("Entered amount does not match invoice amount.")
|
| 256 |
+
|
| 257 |
+
return flags
|
| 258 |
+
|
| 259 |
+
def _parse_date(self, value: str) -> date | None:
|
| 260 |
+
if not value:
|
| 261 |
+
return None
|
| 262 |
+
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%d-%m-%Y", "%m-%d-%Y"):
|
| 263 |
+
try:
|
| 264 |
+
return datetime.strptime(value.strip(), fmt).date()
|
| 265 |
+
except ValueError:
|
| 266 |
+
continue
|
| 267 |
+
match = re.search(r"(\d{4})-(\d{2})-(\d{2})", value)
|
| 268 |
+
if match:
|
| 269 |
+
try:
|
| 270 |
+
return datetime.strptime(match.group(0), "%Y-%m-%d").date()
|
| 271 |
+
except ValueError:
|
| 272 |
+
return None
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
def _looks_like_uk_address(self, value: str) -> bool:
|
| 276 |
+
lower = value.lower()
|
| 277 |
+
uk_terms = ["united kingdom", " uk", "england", "scotland", "wales", "northern ireland", "london"]
|
| 278 |
+
return any(term in lower for term in uk_terms)
|
| 279 |
+
|
| 280 |
+
def _as_bool(self, value) -> bool:
|
| 281 |
+
if isinstance(value, bool):
|
| 282 |
+
return value
|
| 283 |
+
if isinstance(value, str):
|
| 284 |
+
return value.strip().lower() in {"true", "yes", "1"}
|
| 285 |
+
return bool(value)
|
| 286 |
+
|
| 287 |
+
def _short_excerpt(self, text: str) -> str:
|
| 288 |
+
compact = " ".join(text.split())
|
| 289 |
+
return compact[:320] + ("..." if len(compact) > 320 else "")
|
| 290 |
+
|
| 291 |
+
def _build_retrieval_queries(self, retrieval_plan) -> list[str]:
|
| 292 |
+
queries = [
|
| 293 |
+
retrieval_plan.hyde_document,
|
| 294 |
+
retrieval_plan.step_back_question,
|
| 295 |
+
*retrieval_plan.rewritten_queries[:8],
|
| 296 |
+
*retrieval_plan.required_policy_topics[:8],
|
| 297 |
+
]
|
| 298 |
+
return [query for query in self._dedupe_strings(queries) if query]
|
| 299 |
+
|
| 300 |
+
def _docs_to_citations(self, docs) -> list[Citation]:
|
| 301 |
+
return [
|
| 302 |
+
Citation(
|
| 303 |
+
section=str(doc.metadata.get("section", "Policy")),
|
| 304 |
+
title=str(doc.metadata.get("title", "Policy clause")),
|
| 305 |
+
page=str(doc.metadata.get("page", "unknown")),
|
| 306 |
+
excerpt=self._short_excerpt(doc.page_content),
|
| 307 |
+
)
|
| 308 |
+
for doc in docs
|
| 309 |
+
]
|
| 310 |
+
|
| 311 |
+
def _select_citations(self, citations: list[Citation], indexes: list[int]) -> list[Citation]:
|
| 312 |
+
selected = [citations[index] for index in indexes[:8] if 0 <= index < len(citations)]
|
| 313 |
+
return selected[:5] if selected else citations[:5]
|
| 314 |
+
|
| 315 |
+
def _select_report_citations(
|
| 316 |
+
self,
|
| 317 |
+
selected_citations: list[Citation],
|
| 318 |
+
verification_citations: list[Citation],
|
| 319 |
+
) -> list[Citation]:
|
| 320 |
+
evidence = self._dedupe_citations(selected_citations + verification_citations)
|
| 321 |
+
policy_clauses = [
|
| 322 |
+
citation
|
| 323 |
+
for citation in evidence
|
| 324 |
+
if citation.section.lower().startswith(("benefit", "exclusion", "pre-authorisation", "eligibility"))
|
| 325 |
+
]
|
| 326 |
+
return self._dedupe_citations(policy_clauses + evidence)[:5]
|
| 327 |
+
|
| 328 |
+
def _dedupe_citations(self, citations: list[Citation]) -> list[Citation]:
|
| 329 |
+
seen: set[str] = set()
|
| 330 |
+
deduped: list[Citation] = []
|
| 331 |
+
for citation in citations:
|
| 332 |
+
key = f"{citation.section}|{citation.page}|{citation.excerpt[:80]}"
|
| 333 |
+
if key not in seen:
|
| 334 |
+
seen.add(key)
|
| 335 |
+
deduped.append(citation)
|
| 336 |
+
return deduped
|
| 337 |
+
|
| 338 |
+
def _dedupe_strings(self, values: list[str]) -> list[str]:
|
| 339 |
+
seen: set[str] = set()
|
| 340 |
+
deduped: list[str] = []
|
| 341 |
+
for value in values:
|
| 342 |
+
if value and value not in seen:
|
| 343 |
+
seen.add(value)
|
| 344 |
+
deduped.append(value)
|
| 345 |
+
return deduped
|
| 346 |
+
|
| 347 |
+
def _report_conclusion_from_verifier(
|
| 348 |
+
self,
|
| 349 |
+
claim: ClaimInput,
|
| 350 |
+
corrected_status: str,
|
| 351 |
+
verifier_conclusion: str,
|
| 352 |
+
) -> str:
|
| 353 |
+
if corrected_status == "Approved":
|
| 354 |
+
return (
|
| 355 |
+
f"The claim submitted by {claim.patient_name} for {claim.claim_reason} treatment at "
|
| 356 |
+
f"{claim.medical_facility} has been approved based on the submitted claim documents "
|
| 357 |
+
"and the retrieved policy evidence."
|
| 358 |
+
)
|
| 359 |
+
if corrected_status == "Rejected":
|
| 360 |
+
return (
|
| 361 |
+
f"The claim submitted by {claim.patient_name} for {claim.claim_reason} treatment at "
|
| 362 |
+
f"{claim.medical_facility} has been rejected based on the submitted claim documents "
|
| 363 |
+
"and the retrieved policy evidence."
|
| 364 |
+
)
|
| 365 |
+
return verifier_conclusion
|
app/services/llm.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
from langchain_core.language_models.chat_models import BaseChatModel
|
| 8 |
+
from langchain_core.output_parsers import PydanticOutputParser
|
| 9 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 10 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 11 |
+
|
| 12 |
+
from app.services.schemas import Citation, ClaimInput
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class LLMUnavailableError(RuntimeError):
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RetrievalPlan(BaseModel):
|
| 20 |
+
model_config = ConfigDict(extra="ignore")
|
| 21 |
+
|
| 22 |
+
route: Literal["health_claim", "cash_benefit", "exclusion_check", "human_review"]
|
| 23 |
+
hyde_document: str = Field(description="A short hypothetical policy passage that would answer this claim.")
|
| 24 |
+
step_back_question: str = Field(description="A broader policy question behind the claim.")
|
| 25 |
+
rewritten_queries: list[str] = Field(default_factory=list)
|
| 26 |
+
required_policy_topics: list[str] = Field(default_factory=list)
|
| 27 |
+
metadata_filters: list[str] = Field(default_factory=list)
|
| 28 |
+
document_checks: list[str] = Field(default_factory=list)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class EvidenceGrade(BaseModel):
|
| 32 |
+
model_config = ConfigDict(extra="ignore")
|
| 33 |
+
|
| 34 |
+
sufficient: str = Field(description="Use 'true' or 'false'.")
|
| 35 |
+
relevance_check: str
|
| 36 |
+
grounding_check: str
|
| 37 |
+
hallucination_risk: Literal["low", "medium", "high"]
|
| 38 |
+
contradiction_check: str
|
| 39 |
+
missing_questions: list[str] = Field(default_factory=list)
|
| 40 |
+
relevant_citation_indexes: list[int] = Field(default_factory=list)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class DecisionDraft(BaseModel):
|
| 44 |
+
model_config = ConfigDict(extra="ignore")
|
| 45 |
+
|
| 46 |
+
status: Literal["Approved", "Rejected", "Needs Human Review"]
|
| 47 |
+
confidence: float = Field(ge=0, le=1)
|
| 48 |
+
executive_summary: str = Field(
|
| 49 |
+
description="A report-ready paragraph summarizing the claim and decision basis.",
|
| 50 |
+
)
|
| 51 |
+
introduction: str = Field(
|
| 52 |
+
description="A report-ready paragraph introducing the claim analysis and decision.",
|
| 53 |
+
)
|
| 54 |
+
document_verification: str = Field(
|
| 55 |
+
description="A paragraph describing whether the submitted claim documents were verified."
|
| 56 |
+
)
|
| 57 |
+
document_summary: str = Field(
|
| 58 |
+
description="A paragraph summarizing the submitted claim documents and material facts."
|
| 59 |
+
)
|
| 60 |
+
conclusion: str = Field(
|
| 61 |
+
description="A comprehensive report paragraph stating the final status and evidence basis."
|
| 62 |
+
)
|
| 63 |
+
reason_codes: list[str] = Field(default_factory=list)
|
| 64 |
+
flags: list[str] = Field(default_factory=list)
|
| 65 |
+
citation_indexes: list[int] = Field(default_factory=list)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class FinalVerification(BaseModel):
|
| 69 |
+
model_config = ConfigDict(extra="ignore")
|
| 70 |
+
|
| 71 |
+
grounded: str = Field(description="Use 'true' or 'false'.")
|
| 72 |
+
hallucination_risk: Literal["low", "medium", "high"]
|
| 73 |
+
contradiction_found: str = Field(description="Use 'true' or 'false'.")
|
| 74 |
+
corrected_status: Literal["Approved", "Rejected", "Needs Human Review"]
|
| 75 |
+
corrected_conclusion: str
|
| 76 |
+
verifier_notes: list[str] = Field(default_factory=list)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ReportDraft(BaseModel):
|
| 80 |
+
model_config = ConfigDict(extra="ignore")
|
| 81 |
+
|
| 82 |
+
executive_summary: str = Field(description="A report-ready executive summary paragraph.")
|
| 83 |
+
introduction: str = Field(description="A report-ready introduction paragraph.")
|
| 84 |
+
document_verification: str = Field(description="A report-ready document verification paragraph.")
|
| 85 |
+
document_summary: str = Field(description="A report-ready document summary paragraph.")
|
| 86 |
+
conclusion: str = Field(description="A comprehensive final conclusion paragraph.")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def get_claims_llm() -> BaseChatModel:
|
| 90 |
+
provider = os.getenv("CLAIMS_LLM_PROVIDER", "").strip().lower()
|
| 91 |
+
|
| 92 |
+
if not provider:
|
| 93 |
+
if os.getenv("OPENAI_API_KEY"):
|
| 94 |
+
provider = "openai"
|
| 95 |
+
elif os.getenv("GROQ_API_KEY"):
|
| 96 |
+
provider = "groq"
|
| 97 |
+
|
| 98 |
+
if provider == "openai":
|
| 99 |
+
from langchain_openai import ChatOpenAI
|
| 100 |
+
|
| 101 |
+
return ChatOpenAI(
|
| 102 |
+
model=os.getenv("CLAIMS_LLM_MODEL", "gpt-4o-mini"),
|
| 103 |
+
temperature=0,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if provider == "groq":
|
| 107 |
+
from langchain_groq import ChatGroq
|
| 108 |
+
|
| 109 |
+
api_key = os.getenv("GROQ_API_KEY", "")
|
| 110 |
+
if not api_key or api_key == "replace_with_your_groq_api_key":
|
| 111 |
+
raise LLMUnavailableError("Set a real GROQ_API_KEY in .env before processing claims.")
|
| 112 |
+
|
| 113 |
+
return ChatGroq(
|
| 114 |
+
model=os.getenv("CLAIMS_LLM_MODEL", "llama-3.3-70b-versatile"),
|
| 115 |
+
temperature=0,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
raise LLMUnavailableError(
|
| 119 |
+
"No claims LLM configured. Set OPENAI_API_KEY or GROQ_API_KEY, or set CLAIMS_LLM_PROVIDER."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ClaimLLMAgent:
|
| 124 |
+
def __init__(self) -> None:
|
| 125 |
+
self.llm = get_claims_llm()
|
| 126 |
+
self.use_json_mode = "groq" in self.llm.__class__.__module__.lower()
|
| 127 |
+
|
| 128 |
+
def plan_retrieval(self, claim: ClaimInput) -> RetrievalPlan:
|
| 129 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 130 |
+
[
|
| 131 |
+
(
|
| 132 |
+
"system",
|
| 133 |
+
"You are an insurance claim retrieval router. Route the claim and rewrite it into precise "
|
| 134 |
+
"policy-search questions. Use HyDE, multi-query expansion, and step-back abstraction. "
|
| 135 |
+
"Do not decide the claim. Do not invent policy clauses. Ask for the evidence needed "
|
| 136 |
+
"to approve, reject, or route to human review. For Bupa health claims, always retrieve "
|
| 137 |
+
"evidence for: cover requirements, eligible treatment, outpatient consultations for acute "
|
| 138 |
+
"conditions, pre-authorisation, recognised consultants/facilities, outpatient medicines or "
|
| 139 |
+
"drug exclusions, and any relevant exclusions/exceptions. If uploaded claim documents "
|
| 140 |
+
"include an outpatient prescription or medicine charge, search specifically with the policy "
|
| 141 |
+
"terms 'outpatient drugs', 'drugs prescribed for outpatient treatment', and drug exclusions. "
|
| 142 |
+
"If the patient address, facility location, claim text, or uploaded documents indicate a "
|
| 143 |
+
"country outside the UK, search specifically for UK residency eligibility and overseas "
|
| 144 |
+
"treatment exclusions using the policy terms 'resident in the UK throughout the duration "
|
| 145 |
+
"of your cover' and 'overseas treatment outside of the UK'. "
|
| 146 |
+
"The policy may cover categories "
|
| 147 |
+
"such as acute conditions even when the exact diagnosis name is not listed.",
|
| 148 |
+
),
|
| 149 |
+
(
|
| 150 |
+
"human",
|
| 151 |
+
"Claim:\n{claim}\n\nReturn a retrieval plan for a Bupa health insurance policy guide.\n"
|
| 152 |
+
"Return valid JSON matching this schema:\n{schema}",
|
| 153 |
+
),
|
| 154 |
+
]
|
| 155 |
+
)
|
| 156 |
+
chain = self._structured_chain(prompt, RetrievalPlan)
|
| 157 |
+
return chain.invoke(
|
| 158 |
+
{
|
| 159 |
+
"claim": claim.model_dump_json(indent=2),
|
| 160 |
+
"schema": self._schema_text(RetrievalPlan),
|
| 161 |
+
}
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
def grade_evidence(self, claim: ClaimInput, citations: list[Citation]) -> EvidenceGrade:
|
| 165 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 166 |
+
[
|
| 167 |
+
(
|
| 168 |
+
"system",
|
| 169 |
+
"You are a Self-RAG evidence grader. Decide whether the retrieved policy evidence is enough "
|
| 170 |
+
"to support a claim decision. Perform relevance check, grounding check, contradiction "
|
| 171 |
+
"check, and hallucination-risk assessment. Use only the supplied citations. If evidence "
|
| 172 |
+
"is missing or weak, write follow-up retrieval questions. For an acute outpatient claim, "
|
| 173 |
+
"evidence can be sufficient if the citations establish the general cover class and relevant "
|
| 174 |
+
"conditions/exclusions; do not require the policy to name the exact diagnosis if the claim "
|
| 175 |
+
"document identifies it as acute. Evidence rules: (1) Treat a claim reason or uploaded "
|
| 176 |
+
"document diagnosis that says acute as evidence that the claim is presented as acute. "
|
| 177 |
+
"(2) Policy citations do not need to repeat a patient-specific claim amount; invoice text "
|
| 178 |
+
"supports claimed amounts. (3) If the claim documents include billed outpatient medicines "
|
| 179 |
+
"or a prescription, return sufficient='false' unless the supplied citations include the "
|
| 180 |
+
"policy rule that covers or excludes outpatient drugs. A definition of common drugs alone "
|
| 181 |
+
"is not that rule. (4) If the claim documents include a billed diagnostic test, return "
|
| 182 |
+
"sufficient='false' unless a citation covers or excludes outpatient diagnostic tests. "
|
| 183 |
+
"(5) If the patient address, treatment facility, claim text, or uploaded documents show "
|
| 184 |
+
"a non-UK country, return sufficient='false' unless the citations include the UK residency "
|
| 185 |
+
"eligibility rule or the overseas treatment rule.",
|
| 186 |
+
),
|
| 187 |
+
(
|
| 188 |
+
"human",
|
| 189 |
+
"Claim:\n{claim}\n\nRetrieved citations:\n{citations}\n\n"
|
| 190 |
+
"Return valid JSON matching this schema:\n{schema}",
|
| 191 |
+
),
|
| 192 |
+
]
|
| 193 |
+
)
|
| 194 |
+
chain = self._structured_chain(prompt, EvidenceGrade)
|
| 195 |
+
return chain.invoke(
|
| 196 |
+
{
|
| 197 |
+
"claim": claim.model_dump_json(indent=2),
|
| 198 |
+
"citations": _format_citations(citations),
|
| 199 |
+
"schema": self._schema_text(EvidenceGrade),
|
| 200 |
+
}
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
def decide(self, claim: ClaimInput, citations: list[Citation], validation_flags: list[str]) -> DecisionDraft:
|
| 204 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 205 |
+
[
|
| 206 |
+
(
|
| 207 |
+
"system",
|
| 208 |
+
"You are an insurance claims adjudication assistant. Decide Approved, Rejected, or "
|
| 209 |
+
"Needs Human Review using only the provided policy citations and validation flags. "
|
| 210 |
+
"Reject only when the citations support rejection. Approve only when the citations support "
|
| 211 |
+
"coverage and required claim conditions. If evidence is incomplete or conflicting, choose "
|
| 212 |
+
"Needs Human Review. The claim JSON may include uploaded document text; treat invoice, "
|
| 213 |
+
"prescription, medical report, payment receipt, and pre-authorisation text as claim-document "
|
| 214 |
+
"evidence, while policy citations define the rules. Do not require the policy to name the "
|
| 215 |
+
"exact diagnosis when it covers the broader class, such as acute outpatient consultation. "
|
| 216 |
+
"Do not require patient-specific amounts to appear in policy citations; amounts come from "
|
| 217 |
+
"claim documents. Do not require separate medical-necessity proof unless the cited policy "
|
| 218 |
+
"rule requires it and the claim documents do not provide it. "
|
| 219 |
+
"Use facts in the claim documents to connect policy rules to the claim. Do not apply an "
|
| 220 |
+
"A&E, accident and emergency, urgent-care, or walk-in exclusion unless the claim documents "
|
| 221 |
+
"say the treatment followed one of those routes. If the documents show treatment outside "
|
| 222 |
+
"the UK and policy evidence excludes overseas treatment, apply that rule unless the "
|
| 223 |
+
"supplied evidence supports the stated exception. "
|
| 224 |
+
"If part of the amount is excluded, explain the payable/limited portion rather than failing "
|
| 225 |
+
"the whole claim automatically. Write report-ready prose for executive_summary, "
|
| 226 |
+
"introduction, document_verification, document_summary, and conclusion. The conclusion "
|
| 227 |
+
"must be a comprehensive paragraph of two to four sentences that states the claim status, "
|
| 228 |
+
"the submitted treatment facts, the document verification result, and the policy-evidence "
|
| 229 |
+
"basis. Do not return shorthand conclusions such as 'Claim approved' or 'Claim is valid'. "
|
| 230 |
+
"Keep each section suitable for the centered report UI. "
|
| 231 |
+
"Do not say a service or medicine is covered unless the supplied policy citations support "
|
| 232 |
+
"that statement. A definition of common drugs does not by itself prove an outpatient "
|
| 233 |
+
"medicine charge is covered. If a cited exclusion conflicts with a claimed item, state that limitation. "
|
| 234 |
+
"The report fields may be shown directly to the claimant on high-confidence decisions. "
|
| 235 |
+
"Keep them customer-facing: do not mention Self-RAG, grounding, hallucination checks, "
|
| 236 |
+
"internal verification stages, proposed decisions, citation indexes such as [3], or policy "
|
| 237 |
+
"chunk numbers. If policy evidence supports one part of a bill but excludes another, "
|
| 238 |
+
"name the benefit or exclusion plainly and avoid repeating the same human-review sentence. "
|
| 239 |
+
"Do not reveal hidden reasoning.",
|
| 240 |
+
),
|
| 241 |
+
(
|
| 242 |
+
"human",
|
| 243 |
+
"Claim:\n{claim}\n\nValidation flags:\n{flags}\n\nPolicy citations:\n{citations}\n\n"
|
| 244 |
+
"Return valid JSON matching this schema:\n{schema}",
|
| 245 |
+
),
|
| 246 |
+
]
|
| 247 |
+
)
|
| 248 |
+
chain = self._structured_chain(prompt, DecisionDraft)
|
| 249 |
+
return chain.invoke(
|
| 250 |
+
{
|
| 251 |
+
"claim": claim.model_dump_json(indent=2),
|
| 252 |
+
"flags": "\n".join(f"- {flag}" for flag in validation_flags) or "None",
|
| 253 |
+
"citations": _format_citations(citations),
|
| 254 |
+
"schema": self._schema_text(DecisionDraft),
|
| 255 |
+
}
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
def verify_decision(
|
| 259 |
+
self,
|
| 260 |
+
claim: ClaimInput,
|
| 261 |
+
citations: list[Citation],
|
| 262 |
+
decision: DecisionDraft,
|
| 263 |
+
) -> FinalVerification:
|
| 264 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 265 |
+
[
|
| 266 |
+
(
|
| 267 |
+
"system",
|
| 268 |
+
"You are the final Self-RAG verifier. Check whether the proposed claim decision is grounded "
|
| 269 |
+
"in the supplied claim documents and policy citations. Verification rules: "
|
| 270 |
+
"(1) Claim-document text proves patient facts such as invoice amount, treatment date, "
|
| 271 |
+
"diagnosis, medical report, prescription, payment, and pre-authorisation. Policy citations "
|
| 272 |
+
"prove coverage rules. Never require a patient-specific claim amount to appear in policy text. "
|
| 273 |
+
"(2) A policy citation for outpatient consultations for acute conditions can ground an "
|
| 274 |
+
"acute outpatient consultation even if it does not name the exact diagnosis. "
|
| 275 |
+
"(3) Treat a claim reason or uploaded diagnosis that says acute as evidence that the claim "
|
| 276 |
+
"is presented as acute. Never say the documents omit acute status when that text is present. "
|
| 277 |
+
"(4) Do not require separate medical-necessity proof unless the cited policy rule requires it. "
|
| 278 |
+
"(5) A common-drugs definition does not by itself prove an outpatient prescription charge "
|
| 279 |
+
"is covered; use a citation that covers or excludes outpatient drugs. Verify against all "
|
| 280 |
+
"supplied citations, not only the first broad policy citation. "
|
| 281 |
+
"(6) Do not apply A&E, urgent-care, or walk-in exclusions unless claim documents identify "
|
| 282 |
+
"that treatment route. If claim documents show treatment outside the UK and policy evidence "
|
| 283 |
+
"contains the overseas-treatment exclusion, use that rule unless exception evidence is supplied. "
|
| 284 |
+
"If the decision is not grounded, correct it to Needs Human Review. When you write corrected_conclusion, use a "
|
| 285 |
+
"report-ready paragraph rather than a one-line status.",
|
| 286 |
+
),
|
| 287 |
+
(
|
| 288 |
+
"human",
|
| 289 |
+
"Claim:\n{claim}\n\nProposed decision:\n{decision}\n\nPolicy citations:\n{citations}\n\n"
|
| 290 |
+
"Return valid JSON matching this schema:\n{schema}",
|
| 291 |
+
),
|
| 292 |
+
]
|
| 293 |
+
)
|
| 294 |
+
chain = self._structured_chain(prompt, FinalVerification)
|
| 295 |
+
return chain.invoke(
|
| 296 |
+
{
|
| 297 |
+
"claim": claim.model_dump_json(indent=2),
|
| 298 |
+
"decision": decision.model_dump_json(indent=2),
|
| 299 |
+
"citations": _format_citations(citations),
|
| 300 |
+
"schema": self._schema_text(FinalVerification),
|
| 301 |
+
}
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
def write_report(
|
| 305 |
+
self,
|
| 306 |
+
claim: ClaimInput,
|
| 307 |
+
citations: list[Citation],
|
| 308 |
+
status: str,
|
| 309 |
+
) -> ReportDraft:
|
| 310 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 311 |
+
[
|
| 312 |
+
(
|
| 313 |
+
"system",
|
| 314 |
+
"You write the visible insurance claim report after adjudication and Self-RAG "
|
| 315 |
+
"verification. The verified status is authoritative. Use the claim-document text for "
|
| 316 |
+
"invoice, prescription, report, payment, and pre-authorisation facts. Use the policy "
|
| 317 |
+
"evidence for coverage rules. The executive summary, introduction, verification, "
|
| 318 |
+
"summary, and conclusion must agree with the verified status. Do not say that an item "
|
| 319 |
+
"is covered when the supplied policy citations do not support it. Do not require an exact "
|
| 320 |
+
"diagnosis name in policy text when a citation covers the broader treatment class such as "
|
| 321 |
+
"acute outpatient consultations. If the claim or uploaded documents state that the "
|
| 322 |
+
"condition is acute, do not say that the documents omit that fact. A common-drugs "
|
| 323 |
+
"definition does not by itself make an outpatient prescription charge covered. If a cited exclusion affects a billed item, explain that "
|
| 324 |
+
"policy limitation clearly. The conclusion must be two to four sentences and be suitable "
|
| 325 |
+
"for a formal centered report UI. Policy citations do not need to include the patient "
|
| 326 |
+
"specific claim amount; that amount belongs in the claim documents. Keep the report "
|
| 327 |
+
"customer-facing: do not mention Self-RAG, grounding, hallucination checks, internal "
|
| 328 |
+
"verification stages, proposed decisions, citation indexes such as [3], or policy chunk "
|
| 329 |
+
"numbers. If you need to mention evidence, use natural policy names such as outpatient "
|
| 330 |
+
"consultation benefit or outpatient drugs exclusion. When policy evidence supports one "
|
| 331 |
+
"part of the bill but excludes another billed item, explain the covered and excluded "
|
| 332 |
+
"parts plainly and say human review is needed only to determine the payable amount or "
|
| 333 |
+
"final handling of the mixed claim. If an outpatient drugs exclusion is supplied and "
|
| 334 |
+
"the bill includes prescribed outpatient medicines, say that the medicine charge is "
|
| 335 |
+
"excluded or not covered under that exclusion; do not soften it to 'may be limited'. "
|
| 336 |
+
"Do not say 'policy citations' in the customer report; say 'policy evidence', 'policy "
|
| 337 |
+
"terms', or name the relevant benefit or exclusion. Do not repeat the same human-review "
|
| 338 |
+
"sentence within a section. Describe document review as completeness and consistency "
|
| 339 |
+
"checking unless the document text itself proves authenticity. Do not mention A&E, "
|
| 340 |
+
"urgent-care, or walk-in exclusions unless the claim documents state that treatment route. "
|
| 341 |
+
"If the documents show treatment outside the UK and the supplied evidence contains the "
|
| 342 |
+
"overseas-treatment exclusion or UK residency eligibility rule, explain that directly. "
|
| 343 |
+
"Do not reveal hidden reasoning.",
|
| 344 |
+
),
|
| 345 |
+
(
|
| 346 |
+
"human",
|
| 347 |
+
"Verified status: {status}\n\nClaim:\n{claim}\n\nPolicy evidence:\n{citations}\n\n"
|
| 348 |
+
"Return valid JSON matching this schema:\n{schema}",
|
| 349 |
+
),
|
| 350 |
+
]
|
| 351 |
+
)
|
| 352 |
+
chain = self._structured_chain(prompt, ReportDraft)
|
| 353 |
+
return chain.invoke(
|
| 354 |
+
{
|
| 355 |
+
"status": status,
|
| 356 |
+
"claim": claim.model_dump_json(indent=2),
|
| 357 |
+
"citations": _format_citations(citations, include_indexes=False),
|
| 358 |
+
"schema": self._schema_text(ReportDraft),
|
| 359 |
+
}
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
def _schema_text(self, schema: type[BaseModel]) -> str:
|
| 363 |
+
return json.dumps(schema.model_json_schema(), indent=2)
|
| 364 |
+
|
| 365 |
+
def _structured_chain(self, prompt: ChatPromptTemplate, schema: type[BaseModel]):
|
| 366 |
+
if self.use_json_mode:
|
| 367 |
+
# Groq function-calling can reject a response before Pydantic can ignore
|
| 368 |
+
# harmless extra keys. JSON mode keeps the schema enforcement local.
|
| 369 |
+
parser = PydanticOutputParser(pydantic_object=schema)
|
| 370 |
+
return prompt | self.llm.bind(response_format={"type": "json_object"}) | parser
|
| 371 |
+
return prompt | self.llm.with_structured_output(schema)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def _format_citations(citations: list[Citation], include_indexes: bool = True) -> str:
|
| 375 |
+
lines: list[str] = []
|
| 376 |
+
for index, citation in enumerate(citations):
|
| 377 |
+
prefix = f"[{index}] " if include_indexes else ""
|
| 378 |
+
lines.append(f"{prefix}{citation.section} - {citation.title}, page {citation.page}\n{citation.excerpt}")
|
| 379 |
+
return "\n\n".join(lines)
|
app/services/ocr.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import re
|
| 5 |
+
import tempfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import fitz
|
| 9 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from app.services.llm import LLMUnavailableError, get_claims_llm
|
| 13 |
+
from app.services.schemas import InvoiceFields
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class InvoiceExtractor:
|
| 17 |
+
def extract(self, content: bytes, filename: str) -> InvoiceFields:
|
| 18 |
+
is_pdf = filename.lower().endswith(".pdf")
|
| 19 |
+
fast_text = self._extract_pdf_text(content) if is_pdf else ""
|
| 20 |
+
fast_fields = self._parse_invoice_text(fast_text)
|
| 21 |
+
if self._has_prefill_fields(fast_fields):
|
| 22 |
+
return fast_fields
|
| 23 |
+
|
| 24 |
+
docling_text = self._extract_docling_text(content, filename)
|
| 25 |
+
docling_fields = self._parse_invoice_text(docling_text)
|
| 26 |
+
text = docling_text if len(docling_text.strip()) >= len(fast_text.strip()) else fast_text
|
| 27 |
+
fallback = self._merge_fields(primary=docling_fields, fallback=fast_fields)
|
| 28 |
+
if self._has_prefill_fields(fallback):
|
| 29 |
+
return fallback
|
| 30 |
+
|
| 31 |
+
if len(text.strip()) < 30:
|
| 32 |
+
text = self._ocr_bytes(content, filename)
|
| 33 |
+
fallback = self._merge_fields(primary=self._parse_invoice_text(text), fallback=fallback)
|
| 34 |
+
if self._has_prefill_fields(fallback):
|
| 35 |
+
return fallback
|
| 36 |
+
|
| 37 |
+
structured = self._extract_fields_with_langchain(text)
|
| 38 |
+
return self._merge_fields(primary=structured, fallback=fallback)
|
| 39 |
+
|
| 40 |
+
def _extract_docling_text(self, content: bytes, filename: str) -> str:
|
| 41 |
+
suffix = Path(filename).suffix or ".pdf"
|
| 42 |
+
try:
|
| 43 |
+
from docling.datamodel.base_models import InputFormat
|
| 44 |
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
| 45 |
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
| 46 |
+
except Exception:
|
| 47 |
+
return ""
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 51 |
+
tmp.write(content)
|
| 52 |
+
tmp_path = Path(tmp.name)
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
if suffix.lower() == ".pdf":
|
| 56 |
+
options = PdfPipelineOptions()
|
| 57 |
+
options.do_ocr = False
|
| 58 |
+
options.do_table_structure = True
|
| 59 |
+
converter = DocumentConverter(
|
| 60 |
+
format_options={
|
| 61 |
+
InputFormat.PDF: PdfFormatOption(pipeline_options=options),
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
else:
|
| 65 |
+
converter = DocumentConverter()
|
| 66 |
+
|
| 67 |
+
result = converter.convert(tmp_path)
|
| 68 |
+
return result.document.export_to_markdown()
|
| 69 |
+
finally:
|
| 70 |
+
tmp_path.unlink(missing_ok=True)
|
| 71 |
+
except Exception:
|
| 72 |
+
return ""
|
| 73 |
+
|
| 74 |
+
def _extract_fields_with_langchain(self, text: str) -> InvoiceFields | None:
|
| 75 |
+
if len(text.strip()) < 30:
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
llm = get_claims_llm()
|
| 80 |
+
except LLMUnavailableError:
|
| 81 |
+
return None
|
| 82 |
+
except Exception:
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 86 |
+
[
|
| 87 |
+
(
|
| 88 |
+
"system",
|
| 89 |
+
"Extract hospital invoice and claim-package fields into the provided schema. "
|
| 90 |
+
"Use only the document text. If a field is absent, return null. "
|
| 91 |
+
"Normalize dates only when clear. Keep raw_text as the original document text.",
|
| 92 |
+
),
|
| 93 |
+
("human", "Document text:\n{text}"),
|
| 94 |
+
]
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
chain = prompt | llm.with_structured_output(InvoiceFields)
|
| 99 |
+
extracted = chain.invoke({"text": text[:12000]})
|
| 100 |
+
extracted.raw_text = text
|
| 101 |
+
return extracted
|
| 102 |
+
except Exception:
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
def _extract_pdf_text(self, content: bytes) -> str:
|
| 106 |
+
try:
|
| 107 |
+
with fitz.open(stream=content, filetype="pdf") as doc:
|
| 108 |
+
return "\n".join(page.get_text("text") for page in doc)
|
| 109 |
+
except Exception:
|
| 110 |
+
return ""
|
| 111 |
+
|
| 112 |
+
def _ocr_bytes(self, content: bytes, filename: str) -> str:
|
| 113 |
+
try:
|
| 114 |
+
import pytesseract
|
| 115 |
+
except Exception:
|
| 116 |
+
return ""
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
if filename.lower().endswith(".pdf"):
|
| 120 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 121 |
+
pdf = fitz.open(stream=content, filetype="pdf")
|
| 122 |
+
parts: list[str] = []
|
| 123 |
+
for page in pdf:
|
| 124 |
+
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
| 125 |
+
path = Path(tmpdir) / f"page-{page.number}.png"
|
| 126 |
+
pix.save(path)
|
| 127 |
+
parts.append(pytesseract.image_to_string(Image.open(path)))
|
| 128 |
+
return "\n".join(parts)
|
| 129 |
+
|
| 130 |
+
return pytesseract.image_to_string(Image.open(io.BytesIO(content)))
|
| 131 |
+
except Exception:
|
| 132 |
+
return ""
|
| 133 |
+
|
| 134 |
+
def _parse_invoice_text(self, text: str) -> InvoiceFields:
|
| 135 |
+
clean = re.sub(r"[ \t]+", " ", text)
|
| 136 |
+
|
| 137 |
+
def first(patterns: list[str]) -> str | None:
|
| 138 |
+
for pattern in patterns:
|
| 139 |
+
match = re.search(pattern, clean, flags=re.IGNORECASE)
|
| 140 |
+
if match:
|
| 141 |
+
return match.group(1).strip(" :-\n\t")
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
def money(patterns: list[str]) -> float | None:
|
| 145 |
+
value = first(patterns)
|
| 146 |
+
if value is None:
|
| 147 |
+
return None
|
| 148 |
+
amount_match = re.search(r"[\d,.]+", value)
|
| 149 |
+
return float(amount_match.group(0).replace(",", "")) if amount_match else None
|
| 150 |
+
|
| 151 |
+
facility = first([r"##\s*([A-Z][A-Z ]+HOSPITALS?)", r"^\s*([A-Z][A-Z ]+HOSPITALS?)", r"Medical Facility[:\s]+([^\n]+)"])
|
| 152 |
+
patient_name = first([r"(?:Patient )?Name[:\s-]+(.+?)(?=\s+-?\s*Date of Birth|\s+DOB|\n|$)"])
|
| 153 |
+
dob = first([r"Date of Birth[:\s-]+([0-9/\-]+)", r"DOB[:\s-]+([0-9/\-]+)"])
|
| 154 |
+
address = first([r"Address[:\s-]+(.+?)(?=\s+-?\s*Phone|\n|$)"])
|
| 155 |
+
phone = first([r"Phone Number[:\s-]+(.+?)(?=\s+-?\s*Policy Number|\s+Service Details|\n|$)", r"Phone[:\s-]+(.+?)(?=\s+-?\s*Policy Number|\s+Service Details|\n|$)"])
|
| 156 |
+
date_of_service = first([r"Date of Service[:\s-]+([0-9/\-]+)", r"Date of Treatment[:\s-]+([0-9/\-]+)"])
|
| 157 |
+
diagnosis = first([r"Diagnosis[:\s-]+(.+?)(?=\s+-?\s*Treatment Type|\s+Prescribed Medicines|\s+Details:|\n|$)", r"Claim Reason[:\s-]+([^\n]+)"])
|
| 158 |
+
total = money([r"Amount payable[:\s=-]+([^\n]+)", r"Total charge[:\s=-]+([^\n]+)", r"Total Claim Amount[:\s=-]+([^\n]+)"])
|
| 159 |
+
doctor_fee = money([r"Doctor'?s (?:consultation )?fee\s*[-:=]\s*([0-9,.]+)", r"Doctor'?s fee[:\s=-]+(.+?)(?=\s+Medicines?|\n|$)"])
|
| 160 |
+
medicine_cost = money([r"Prescribed medicines\s*[-:=]\s*([0-9,.]+)", r"Medicines?\s*[-:=]\s*([0-9,.]+)", r"Medicine cost\s*[-:=]\s*([0-9,.]+)"])
|
| 161 |
+
|
| 162 |
+
return InvoiceFields(
|
| 163 |
+
raw_text=text,
|
| 164 |
+
patient_name=patient_name,
|
| 165 |
+
date_of_birth=dob,
|
| 166 |
+
address=address,
|
| 167 |
+
phone=phone,
|
| 168 |
+
date_of_treatment=date_of_service,
|
| 169 |
+
diagnosis=diagnosis,
|
| 170 |
+
medical_facility=facility,
|
| 171 |
+
doctor_fee=doctor_fee,
|
| 172 |
+
medicine_cost=medicine_cost,
|
| 173 |
+
amount_payable=total,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
def _merge_fields(self, primary: InvoiceFields | None, fallback: InvoiceFields) -> InvoiceFields:
|
| 177 |
+
if primary is None:
|
| 178 |
+
return fallback
|
| 179 |
+
|
| 180 |
+
data = primary.model_dump()
|
| 181 |
+
fallback_data = fallback.model_dump()
|
| 182 |
+
for key, value in data.items():
|
| 183 |
+
if value in (None, "") and fallback_data.get(key) not in (None, ""):
|
| 184 |
+
data[key] = fallback_data[key]
|
| 185 |
+
|
| 186 |
+
# Payable amount is the correct claim amount when both total charge and discounted amount exist.
|
| 187 |
+
if fallback.amount_payable is not None and re.search(r"amount payable", fallback.raw_text, flags=re.IGNORECASE):
|
| 188 |
+
data["amount_payable"] = fallback.amount_payable
|
| 189 |
+
|
| 190 |
+
# Regex fallback is often more precise for flattened Docling invoice lines.
|
| 191 |
+
for key in ("patient_name", "date_of_birth", "address", "phone", "date_of_treatment", "diagnosis", "medical_facility"):
|
| 192 |
+
if fallback_data.get(key) not in (None, ""):
|
| 193 |
+
data[key] = fallback_data[key]
|
| 194 |
+
|
| 195 |
+
if not data.get("raw_text"):
|
| 196 |
+
data["raw_text"] = fallback.raw_text
|
| 197 |
+
return InvoiceFields(**data)
|
| 198 |
+
|
| 199 |
+
def _has_prefill_fields(self, fields: InvoiceFields) -> bool:
|
| 200 |
+
required = (
|
| 201 |
+
fields.patient_name,
|
| 202 |
+
fields.address,
|
| 203 |
+
fields.date_of_treatment,
|
| 204 |
+
fields.diagnosis,
|
| 205 |
+
fields.medical_facility,
|
| 206 |
+
fields.amount_payable,
|
| 207 |
+
)
|
| 208 |
+
return all(value not in (None, "") for value in required)
|
app/services/rag.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import fitz
|
| 7 |
+
from langchain_core.documents import Document
|
| 8 |
+
from langchain_community.retrievers import BM25Retriever
|
| 9 |
+
from langchain_community.vectorstores import FAISS
|
| 10 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
_POLICY_DOCUMENT_CACHE: dict[str, list[Document]] = {}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class PolicyRAG:
|
| 17 |
+
def __init__(self, policy_path: Path) -> None:
|
| 18 |
+
self.policy_path = policy_path
|
| 19 |
+
self.policy_version = self._policy_version()
|
| 20 |
+
self.last_retrieval_trace: list[dict[str, Any]] = []
|
| 21 |
+
self.documents = self._load_documents()
|
| 22 |
+
self.embeddings = None
|
| 23 |
+
self.reranker = None
|
| 24 |
+
self.bm25_retriever, self.dense_retriever = self._build_retrievers()
|
| 25 |
+
|
| 26 |
+
def _policy_version(self) -> str:
|
| 27 |
+
stat = self.policy_path.stat()
|
| 28 |
+
return f"{self.policy_path.name}:{stat.st_size}:{int(stat.st_mtime)}"
|
| 29 |
+
|
| 30 |
+
def _load_documents(self) -> list[Document]:
|
| 31 |
+
cache_key = self.policy_version
|
| 32 |
+
if cache_key in _POLICY_DOCUMENT_CACHE:
|
| 33 |
+
return _POLICY_DOCUMENT_CACHE[cache_key]
|
| 34 |
+
|
| 35 |
+
docs = self._load_pdf_documents() if self.policy_path.suffix.lower() == ".pdf" else self._load_text_documents()
|
| 36 |
+
|
| 37 |
+
splitter = RecursiveCharacterTextSplitter(chunk_size=900, chunk_overlap=150)
|
| 38 |
+
split_docs = splitter.split_documents(docs)
|
| 39 |
+
_POLICY_DOCUMENT_CACHE[cache_key] = split_docs
|
| 40 |
+
return split_docs
|
| 41 |
+
|
| 42 |
+
def _load_text_documents(self) -> list[Document]:
|
| 43 |
+
raw = self.policy_path.read_text(encoding="utf-8")
|
| 44 |
+
docs: list[Document] = []
|
| 45 |
+
section = "Policy"
|
| 46 |
+
title = "Bupa Health policy"
|
| 47 |
+
page = "unknown"
|
| 48 |
+
buffer: list[str] = []
|
| 49 |
+
|
| 50 |
+
def flush() -> None:
|
| 51 |
+
if buffer:
|
| 52 |
+
docs.append(
|
| 53 |
+
Document(
|
| 54 |
+
page_content="\n".join(buffer).strip(),
|
| 55 |
+
metadata={"section": section, "title": title, "page": page, "source": str(self.policy_path)},
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
for line in raw.splitlines():
|
| 60 |
+
if line.startswith("## "):
|
| 61 |
+
flush()
|
| 62 |
+
buffer = []
|
| 63 |
+
heading = line[3:].strip()
|
| 64 |
+
pieces = [part.strip() for part in heading.split("|")]
|
| 65 |
+
section = pieces[0]
|
| 66 |
+
title = pieces[1] if len(pieces) > 1 else pieces[0]
|
| 67 |
+
page = pieces[2].replace("page", "").strip() if len(pieces) > 2 else "unknown"
|
| 68 |
+
elif line.strip():
|
| 69 |
+
buffer.append(line.strip())
|
| 70 |
+
flush()
|
| 71 |
+
return docs
|
| 72 |
+
|
| 73 |
+
def _load_pdf_documents(self) -> list[Document]:
|
| 74 |
+
docs: list[Document] = []
|
| 75 |
+
with fitz.open(self.policy_path) as pdf:
|
| 76 |
+
for page_index, page in enumerate(pdf, start=1):
|
| 77 |
+
text = page.get_text("text")
|
| 78 |
+
if not text.strip():
|
| 79 |
+
continue
|
| 80 |
+
for block_index, block in enumerate(self._split_policy_page(text), start=1):
|
| 81 |
+
section, title = self._infer_section_title(block, page_index, block_index)
|
| 82 |
+
docs.append(
|
| 83 |
+
Document(
|
| 84 |
+
page_content=block,
|
| 85 |
+
metadata={
|
| 86 |
+
"section": section,
|
| 87 |
+
"title": title,
|
| 88 |
+
"page": str(page_index),
|
| 89 |
+
"source": str(self.policy_path),
|
| 90 |
+
},
|
| 91 |
+
)
|
| 92 |
+
)
|
| 93 |
+
if not docs:
|
| 94 |
+
raise ValueError(f"No text could be extracted from policy PDF: {self.policy_path}")
|
| 95 |
+
return docs
|
| 96 |
+
|
| 97 |
+
def _split_policy_page(self, text: str) -> list[str]:
|
| 98 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 99 |
+
blocks: list[list[str]] = []
|
| 100 |
+
current: list[str] = []
|
| 101 |
+
|
| 102 |
+
for line in lines:
|
| 103 |
+
is_heading = self._looks_like_heading(line)
|
| 104 |
+
if is_heading and current:
|
| 105 |
+
blocks.append(current)
|
| 106 |
+
current = [line]
|
| 107 |
+
else:
|
| 108 |
+
current.append(line)
|
| 109 |
+
|
| 110 |
+
if current:
|
| 111 |
+
blocks.append(current)
|
| 112 |
+
|
| 113 |
+
merged: list[str] = []
|
| 114 |
+
buffer = ""
|
| 115 |
+
for block in blocks:
|
| 116 |
+
candidate = " ".join(block)
|
| 117 |
+
if len(buffer) < 260:
|
| 118 |
+
buffer = f"{buffer} {candidate}".strip()
|
| 119 |
+
else:
|
| 120 |
+
merged.append(buffer)
|
| 121 |
+
buffer = candidate
|
| 122 |
+
if buffer:
|
| 123 |
+
merged.append(buffer)
|
| 124 |
+
return merged
|
| 125 |
+
|
| 126 |
+
def _looks_like_heading(self, line: str) -> bool:
|
| 127 |
+
if len(line) > 90:
|
| 128 |
+
return False
|
| 129 |
+
lower = line.lower()
|
| 130 |
+
heading_terms = [
|
| 131 |
+
"benefit",
|
| 132 |
+
"exclusion",
|
| 133 |
+
"what is covered",
|
| 134 |
+
"what isn't covered",
|
| 135 |
+
"what isnt covered",
|
| 136 |
+
"eligibility",
|
| 137 |
+
"pre-authorisation",
|
| 138 |
+
"pre-authorization",
|
| 139 |
+
"claim",
|
| 140 |
+
"complain",
|
| 141 |
+
"definition",
|
| 142 |
+
"privacy",
|
| 143 |
+
]
|
| 144 |
+
numbered = line[:2].strip(".").isdigit() or line[:3].strip(".").isdigit()
|
| 145 |
+
title_case = line[:1].isupper() and sum(1 for char in line if char.isalpha()) > 4
|
| 146 |
+
return numbered or any(term in lower for term in heading_terms) or (title_case and len(line.split()) <= 7)
|
| 147 |
+
|
| 148 |
+
def _infer_section_title(self, block: str, page_index: int, block_index: int) -> tuple[str, str]:
|
| 149 |
+
first = block.split(". ")[0].strip()
|
| 150 |
+
first = first[:80] if first else f"Policy page {page_index}"
|
| 151 |
+
lower = block.lower()
|
| 152 |
+
numbered_exclusion = self._extract_numbered_exclusion(block)
|
| 153 |
+
|
| 154 |
+
if numbered_exclusion:
|
| 155 |
+
section = numbered_exclusion
|
| 156 |
+
elif "exclusion" in lower:
|
| 157 |
+
section = self._extract_labeled_section(block, "Exclusion") or f"Exclusion evidence p{page_index}.{block_index}"
|
| 158 |
+
elif "benefit" in lower:
|
| 159 |
+
section = self._extract_labeled_section(block, "Benefit") or f"Benefit evidence p{page_index}.{block_index}"
|
| 160 |
+
elif "pre-authorisation" in lower or "pre-authorization" in lower:
|
| 161 |
+
section = "Pre-authorisation"
|
| 162 |
+
elif "eligib" in lower or "resident in the uk" in lower:
|
| 163 |
+
section = "Eligibility"
|
| 164 |
+
else:
|
| 165 |
+
section = f"Policy p{page_index}.{block_index}"
|
| 166 |
+
|
| 167 |
+
return section, first
|
| 168 |
+
|
| 169 |
+
def _extract_labeled_section(self, block: str, label: str) -> str | None:
|
| 170 |
+
import re
|
| 171 |
+
|
| 172 |
+
match = re.search(rf"\b({label})\s+([A-Z]?[A-Z0-9.]+)?", block, flags=re.IGNORECASE)
|
| 173 |
+
if not match:
|
| 174 |
+
return None
|
| 175 |
+
suffix = (match.group(2) or "").strip()
|
| 176 |
+
return f"{label} {suffix}".strip()
|
| 177 |
+
|
| 178 |
+
def _extract_numbered_exclusion(self, block: str) -> str | None:
|
| 179 |
+
import re
|
| 180 |
+
|
| 181 |
+
lower = block.lower()
|
| 182 |
+
if "not covered" not in lower and "isn" not in lower and "arent covered" not in lower:
|
| 183 |
+
return None
|
| 184 |
+
match = re.match(r"\s*(\d{1,2})\s+[A-Z]", block)
|
| 185 |
+
return f"Exclusion {match.group(1)}" if match else None
|
| 186 |
+
|
| 187 |
+
def _build_retrievers(self):
|
| 188 |
+
bm25 = BM25Retriever.from_documents(self.documents)
|
| 189 |
+
bm25.k = 10
|
| 190 |
+
|
| 191 |
+
try:
|
| 192 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 193 |
+
|
| 194 |
+
self.embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 195 |
+
vector_store = FAISS.from_documents(self.documents, self.embeddings)
|
| 196 |
+
dense = vector_store.as_retriever(search_kwargs={"k": 10})
|
| 197 |
+
return bm25, dense
|
| 198 |
+
except Exception:
|
| 199 |
+
return bm25, None
|
| 200 |
+
|
| 201 |
+
def retrieve(self, queries: list[str]) -> list[Document]:
|
| 202 |
+
self.last_retrieval_trace = []
|
| 203 |
+
seen: set[str] = set()
|
| 204 |
+
candidates: list[Document] = []
|
| 205 |
+
anchors: list[Document] = []
|
| 206 |
+
for query in queries:
|
| 207 |
+
query_docs = self._hybrid_search(query)
|
| 208 |
+
for doc in query_docs[:2]:
|
| 209 |
+
key = f"{doc.metadata.get('section')}::{doc.page_content[:120]}"
|
| 210 |
+
if key not in seen:
|
| 211 |
+
seen.add(key)
|
| 212 |
+
anchors.append(doc)
|
| 213 |
+
candidates.append(doc)
|
| 214 |
+
for doc in query_docs:
|
| 215 |
+
key = f"{doc.metadata.get('section')}::{doc.page_content[:120]}"
|
| 216 |
+
if key not in seen:
|
| 217 |
+
seen.add(key)
|
| 218 |
+
candidates.append(doc)
|
| 219 |
+
|
| 220 |
+
reranked = self._dedupe_documents(anchors + self._rerank(" ".join(queries), candidates))[:12]
|
| 221 |
+
self.last_retrieval_trace.append(
|
| 222 |
+
{
|
| 223 |
+
"stage": "reranking",
|
| 224 |
+
"reranker": "flashrank/ms-marco-MiniLM-L-12-v2",
|
| 225 |
+
"candidate_count": len(candidates),
|
| 226 |
+
"per_query_anchor_count": len(anchors),
|
| 227 |
+
"selected_count": len(reranked),
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
return reranked
|
| 231 |
+
|
| 232 |
+
def _hybrid_search(self, query: str) -> list[Document]:
|
| 233 |
+
bm25_ranked = self.bm25_retriever.invoke(query)
|
| 234 |
+
ranked_lists = [bm25_ranked]
|
| 235 |
+
dense_ranked = []
|
| 236 |
+
if self.dense_retriever is not None:
|
| 237 |
+
dense_ranked = self.dense_retriever.invoke(query)
|
| 238 |
+
ranked_lists.append(dense_ranked)
|
| 239 |
+
|
| 240 |
+
scores: dict[str, float] = {}
|
| 241 |
+
docs_by_key: dict[str, Document] = {}
|
| 242 |
+
for ranked in ranked_lists:
|
| 243 |
+
for rank, doc in enumerate(ranked, start=1):
|
| 244 |
+
key = f"{doc.metadata.get('section')}::{doc.page_content[:120]}"
|
| 245 |
+
docs_by_key[key] = doc
|
| 246 |
+
scores[key] = scores.get(key, 0.0) + 1.0 / (60 + rank)
|
| 247 |
+
|
| 248 |
+
fused = [docs_by_key[key] for key, _ in sorted(scores.items(), key=lambda item: item[1], reverse=True)]
|
| 249 |
+
self.last_retrieval_trace.append(
|
| 250 |
+
{
|
| 251 |
+
"stage": "hybrid_retrieval",
|
| 252 |
+
"query": query,
|
| 253 |
+
"dense_results": len(dense_ranked),
|
| 254 |
+
"sparse_results": len(bm25_ranked),
|
| 255 |
+
"fusion": "reciprocal_rank_fusion",
|
| 256 |
+
"fused_results": len(fused),
|
| 257 |
+
}
|
| 258 |
+
)
|
| 259 |
+
return fused
|
| 260 |
+
|
| 261 |
+
def embed_query(self, query: str) -> list[float]:
|
| 262 |
+
if self.embeddings is not None:
|
| 263 |
+
return self.embeddings.embed_query(query)
|
| 264 |
+
import hashlib
|
| 265 |
+
|
| 266 |
+
vector = [0.0] * 384
|
| 267 |
+
for word in query.lower().split():
|
| 268 |
+
slot = int(hashlib.sha256(word.encode("utf-8")).hexdigest()[:8], 16) % len(vector)
|
| 269 |
+
vector[slot] += 1.0
|
| 270 |
+
return vector
|
| 271 |
+
|
| 272 |
+
def _rerank(self, query: str, docs: list[Document]) -> list[Document]:
|
| 273 |
+
if not docs:
|
| 274 |
+
return []
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
from flashrank import Ranker, RerankRequest
|
| 278 |
+
|
| 279 |
+
if self.reranker is None:
|
| 280 |
+
self.reranker = Ranker(model_name="ms-marco-MiniLM-L-12-v2")
|
| 281 |
+
passages = [
|
| 282 |
+
{"id": str(index), "text": doc.page_content, "meta": doc.metadata}
|
| 283 |
+
for index, doc in enumerate(docs)
|
| 284 |
+
]
|
| 285 |
+
results = self.reranker.rerank(RerankRequest(query=query, passages=passages))
|
| 286 |
+
ordered = [docs[int(result["id"])] for result in results]
|
| 287 |
+
return ordered
|
| 288 |
+
except Exception:
|
| 289 |
+
keywords = {word.lower() for word in query.split() if len(word) > 3}
|
| 290 |
+
|
| 291 |
+
def score(doc: Document) -> int:
|
| 292 |
+
text = doc.page_content.lower()
|
| 293 |
+
metadata = " ".join(str(v).lower() for v in doc.metadata.values())
|
| 294 |
+
return sum(1 for word in keywords if word in text or word in metadata)
|
| 295 |
+
|
| 296 |
+
return sorted(docs, key=score, reverse=True)
|
| 297 |
+
|
| 298 |
+
def _dedupe_documents(self, docs: list[Document]) -> list[Document]:
|
| 299 |
+
seen: set[str] = set()
|
| 300 |
+
deduped: list[Document] = []
|
| 301 |
+
for doc in docs:
|
| 302 |
+
key = f"{doc.metadata.get('section')}::{doc.page_content[:120]}"
|
| 303 |
+
if key not in seen:
|
| 304 |
+
seen.add(key)
|
| 305 |
+
deduped.append(doc)
|
| 306 |
+
return deduped
|
app/services/schemas.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class InvoiceFields(BaseModel):
|
| 7 |
+
raw_text: str = ""
|
| 8 |
+
patient_name: str | None = None
|
| 9 |
+
date_of_birth: str | None = None
|
| 10 |
+
address: str | None = None
|
| 11 |
+
phone: str | None = None
|
| 12 |
+
date_of_treatment: str | None = None
|
| 13 |
+
diagnosis: str | None = None
|
| 14 |
+
medical_facility: str | None = None
|
| 15 |
+
doctor_fee: float | None = None
|
| 16 |
+
medicine_cost: float | None = None
|
| 17 |
+
amount_payable: float | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ClaimInput(BaseModel):
|
| 21 |
+
patient_name: str
|
| 22 |
+
patient_address: str
|
| 23 |
+
claim_item: str = "General Practitioner"
|
| 24 |
+
date_of_treatment: str
|
| 25 |
+
medical_facility: str
|
| 26 |
+
claim_reason: str
|
| 27 |
+
claim_amount: float
|
| 28 |
+
invoice_hash: str | None = None
|
| 29 |
+
extracted_invoice: InvoiceFields | None = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Citation(BaseModel):
|
| 33 |
+
section: str
|
| 34 |
+
title: str
|
| 35 |
+
page: str
|
| 36 |
+
excerpt: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ClaimDecision(BaseModel):
|
| 40 |
+
status: str
|
| 41 |
+
confidence: float = Field(ge=0, le=1)
|
| 42 |
+
patient_name: str
|
| 43 |
+
patient_address: str
|
| 44 |
+
claim_item: str
|
| 45 |
+
medical_facility: str
|
| 46 |
+
date_of_treatment: str
|
| 47 |
+
total_claim_amount: float
|
| 48 |
+
executive_summary: str
|
| 49 |
+
introduction: str
|
| 50 |
+
claim_description: str
|
| 51 |
+
document_verification: str
|
| 52 |
+
document_summary: str
|
| 53 |
+
conclusion: str
|
| 54 |
+
reason_codes: list[str]
|
| 55 |
+
flags: list[str]
|
| 56 |
+
citations: list[Citation]
|
| 57 |
+
cache_hit: bool = False
|
| 58 |
+
pipeline_trace: dict = Field(default_factory=dict)
|
frontend/app.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const form = document.querySelector("#claim-form");
|
| 2 |
+
const invoiceInput = document.querySelector("#invoice");
|
| 3 |
+
const uploadStatus = document.querySelector("#upload-status");
|
| 4 |
+
const submitBtn = document.querySelector("#submit-btn");
|
| 5 |
+
const backBtn = document.querySelector("#back-btn");
|
| 6 |
+
let invoiceHash = "";
|
| 7 |
+
|
| 8 |
+
const fields = {
|
| 9 |
+
patient_name: document.querySelector("#patient_name"),
|
| 10 |
+
patient_address: document.querySelector("#patient_address"),
|
| 11 |
+
claim_item: document.querySelector("#claim_item"),
|
| 12 |
+
date_of_treatment: document.querySelector("#date_of_treatment"),
|
| 13 |
+
medical_facility: document.querySelector("#medical_facility"),
|
| 14 |
+
claim_reason: document.querySelector("#claim_reason"),
|
| 15 |
+
claim_amount: document.querySelector("#claim_amount"),
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
window.addEventListener("pageshow", () => {
|
| 19 |
+
form.reset();
|
| 20 |
+
invoiceHash = "";
|
| 21 |
+
uploadStatus.textContent = "";
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
invoiceInput.addEventListener("change", async () => {
|
| 25 |
+
const file = invoiceInput.files[0];
|
| 26 |
+
if (!file) return;
|
| 27 |
+
|
| 28 |
+
uploadStatus.textContent = "Extracting invoice fields...";
|
| 29 |
+
const data = new FormData();
|
| 30 |
+
data.append("invoice", file);
|
| 31 |
+
|
| 32 |
+
try {
|
| 33 |
+
const response = await fetch("/api/extract-invoice", {
|
| 34 |
+
method: "POST",
|
| 35 |
+
body: data,
|
| 36 |
+
});
|
| 37 |
+
if (!response.ok) throw new Error("Invoice extraction failed");
|
| 38 |
+
const payload = await response.json();
|
| 39 |
+
invoiceHash = payload.invoice_hash;
|
| 40 |
+
const extracted = payload.fields;
|
| 41 |
+
|
| 42 |
+
if (extracted.patient_name) fields.patient_name.value = extracted.patient_name;
|
| 43 |
+
if (extracted.address) fields.patient_address.value = extracted.address;
|
| 44 |
+
if (extracted.date_of_treatment) fields.date_of_treatment.value = normalizeDate(extracted.date_of_treatment);
|
| 45 |
+
if (extracted.medical_facility) fields.medical_facility.value = extracted.medical_facility;
|
| 46 |
+
if (extracted.diagnosis) fields.claim_reason.value = extracted.diagnosis;
|
| 47 |
+
if (extracted.amount_payable) fields.claim_amount.value = extracted.amount_payable;
|
| 48 |
+
|
| 49 |
+
uploadStatus.textContent = "Invoice extracted. Please review the fields before submitting.";
|
| 50 |
+
} catch (error) {
|
| 51 |
+
uploadStatus.textContent = "Could not extract invoice automatically. Please enter the details manually.";
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
form.addEventListener("submit", async (event) => {
|
| 56 |
+
event.preventDefault();
|
| 57 |
+
document.querySelector("#result").hidden = true;
|
| 58 |
+
submitBtn.disabled = true;
|
| 59 |
+
submitBtn.textContent = "Processing...";
|
| 60 |
+
|
| 61 |
+
const data = new FormData();
|
| 62 |
+
data.append("patient_name", fields.patient_name.value);
|
| 63 |
+
data.append("patient_address", fields.patient_address.value);
|
| 64 |
+
data.append("claim_item", fields.claim_item.value);
|
| 65 |
+
data.append("date_of_treatment", fields.date_of_treatment.value);
|
| 66 |
+
data.append("medical_facility", fields.medical_facility.value);
|
| 67 |
+
data.append("claim_reason", fields.claim_reason.value);
|
| 68 |
+
data.append("claim_amount", fields.claim_amount.value);
|
| 69 |
+
data.append("invoice_hash", invoiceHash);
|
| 70 |
+
|
| 71 |
+
if (invoiceInput.files[0]) {
|
| 72 |
+
data.append("invoice", invoiceInput.files[0]);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
const response = await fetch("/api/process-claim", {
|
| 77 |
+
method: "POST",
|
| 78 |
+
body: data,
|
| 79 |
+
});
|
| 80 |
+
if (!response.ok) {
|
| 81 |
+
const errorPayload = await response.json().catch(() => ({}));
|
| 82 |
+
throw new Error(errorPayload.detail || "Claim processing failed");
|
| 83 |
+
}
|
| 84 |
+
const result = await response.json();
|
| 85 |
+
if (!matchesSubmittedClaim(result)) {
|
| 86 |
+
throw new Error("The server returned a stale or mismatched claim result. Please refresh and try again.");
|
| 87 |
+
}
|
| 88 |
+
renderResult(result);
|
| 89 |
+
} catch (error) {
|
| 90 |
+
alert(error.message || "Claim processing failed. Please check the backend logs.");
|
| 91 |
+
} finally {
|
| 92 |
+
submitBtn.disabled = false;
|
| 93 |
+
submitBtn.textContent = "Process Claim";
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
function matchesSubmittedClaim(result) {
|
| 98 |
+
const expectedName = normalizeText(fields.patient_name.value);
|
| 99 |
+
const expectedFacility = normalizeText(fields.medical_facility.value);
|
| 100 |
+
const expectedAmount = Number(fields.claim_amount.value);
|
| 101 |
+
return (
|
| 102 |
+
normalizeText(result.patient_name) === expectedName &&
|
| 103 |
+
normalizeText(result.medical_facility) === expectedFacility &&
|
| 104 |
+
Number(result.total_claim_amount) === expectedAmount
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
backBtn.addEventListener("click", () => {
|
| 109 |
+
document.querySelector("#result").hidden = true;
|
| 110 |
+
form.hidden = false;
|
| 111 |
+
form.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
function renderResult(result) {
|
| 115 |
+
const panel = document.querySelector("#result");
|
| 116 |
+
|
| 117 |
+
document.querySelector("#r-name").textContent = result.patient_name;
|
| 118 |
+
document.querySelector("#r-address").textContent = result.patient_address;
|
| 119 |
+
document.querySelector("#r-claim-item").textContent = result.claim_item;
|
| 120 |
+
document.querySelector("#r-facility").textContent = result.medical_facility;
|
| 121 |
+
document.querySelector("#r-date").textContent = result.date_of_treatment;
|
| 122 |
+
document.querySelector("#r-amount").textContent = result.total_claim_amount;
|
| 123 |
+
document.querySelector("#r-executive-summary").textContent = result.executive_summary;
|
| 124 |
+
document.querySelector("#r-introduction").textContent = result.introduction;
|
| 125 |
+
document.querySelector("#r-description").textContent = result.claim_description;
|
| 126 |
+
document.querySelector("#r-verification").textContent = result.document_verification;
|
| 127 |
+
document.querySelector("#r-summary").textContent = result.document_summary;
|
| 128 |
+
document.querySelector("#r-conclusion").textContent = result.conclusion;
|
| 129 |
+
|
| 130 |
+
const citations = document.querySelector("#r-citations");
|
| 131 |
+
citations.innerHTML = "";
|
| 132 |
+
result.citations.forEach((citation) => {
|
| 133 |
+
const node = document.createElement("div");
|
| 134 |
+
node.className = "citation";
|
| 135 |
+
node.innerHTML = `
|
| 136 |
+
<strong>${escapeHtml(citation.section)} - ${escapeHtml(citation.title)} · page ${escapeHtml(citation.page)}</strong>
|
| 137 |
+
<span>${escapeHtml(citation.excerpt)}</span>
|
| 138 |
+
`;
|
| 139 |
+
citations.appendChild(node);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
panel.hidden = false;
|
| 143 |
+
form.hidden = true;
|
| 144 |
+
panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function normalizeDate(value) {
|
| 148 |
+
const trimmed = value.trim();
|
| 149 |
+
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
| 150 |
+
if (iso) return trimmed;
|
| 151 |
+
const slash = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
| 152 |
+
if (!slash) return trimmed;
|
| 153 |
+
const day = slash[1].padStart(2, "0");
|
| 154 |
+
const month = slash[2].padStart(2, "0");
|
| 155 |
+
return `${slash[3]}-${month}-${day}`;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function escapeHtml(value) {
|
| 159 |
+
return String(value)
|
| 160 |
+
.replaceAll("&", "&")
|
| 161 |
+
.replaceAll("<", "<")
|
| 162 |
+
.replaceAll(">", ">")
|
| 163 |
+
.replaceAll('"', """)
|
| 164 |
+
.replaceAll("'", "'");
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function normalizeText(value) {
|
| 168 |
+
return String(value || "").trim().toLowerCase();
|
| 169 |
+
}
|
frontend/index.html
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Insurance Claims</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css?v=blank-claim-form-v1" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main class="app-shell">
|
| 11 |
+
<section class="intro-pane">
|
| 12 |
+
<div class="intro-content">
|
| 13 |
+
<h1>Insurance Claims</h1>
|
| 14 |
+
<p>Please provide accurate information to verify your claim.</p>
|
| 15 |
+
<div class="shield-art" aria-hidden="true">
|
| 16 |
+
<div class="shield">
|
| 17 |
+
<div class="key-ring"></div>
|
| 18 |
+
<div class="key-stem"></div>
|
| 19 |
+
<div class="key-bit"></div>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="check-bubble">✓</div>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<section class="claim-pane">
|
| 27 |
+
<form id="claim-form" class="claim-form">
|
| 28 |
+
<label class="full">
|
| 29 |
+
<span>Submit claim for</span>
|
| 30 |
+
<input id="patient_name" name="patient_name" type="text" placeholder="Patient name" required />
|
| 31 |
+
</label>
|
| 32 |
+
|
| 33 |
+
<label>
|
| 34 |
+
<span>Claim item</span>
|
| 35 |
+
<select id="claim_item" name="claim_item" required>
|
| 36 |
+
<option value="" selected disabled>Select claim item</option>
|
| 37 |
+
<option>General Practitioner</option>
|
| 38 |
+
<option>Outpatient Consultation</option>
|
| 39 |
+
<option>Medicine</option>
|
| 40 |
+
<option>Diagnostic Test</option>
|
| 41 |
+
<option>Hospital Treatment</option>
|
| 42 |
+
</select>
|
| 43 |
+
</label>
|
| 44 |
+
|
| 45 |
+
<label>
|
| 46 |
+
<span>Claim Reason</span>
|
| 47 |
+
<input id="claim_reason" name="claim_reason" type="text" placeholder="Claim reason" required />
|
| 48 |
+
</label>
|
| 49 |
+
|
| 50 |
+
<label class="full">
|
| 51 |
+
<span>Patient Address</span>
|
| 52 |
+
<input id="patient_address" name="patient_address" type="text" placeholder="Patient address" required />
|
| 53 |
+
</label>
|
| 54 |
+
|
| 55 |
+
<label>
|
| 56 |
+
<span>Date of treatment</span>
|
| 57 |
+
<input id="date_of_treatment" name="date_of_treatment" type="date" required />
|
| 58 |
+
</label>
|
| 59 |
+
|
| 60 |
+
<span></span>
|
| 61 |
+
|
| 62 |
+
<label>
|
| 63 |
+
<span>Medical Facility</span>
|
| 64 |
+
<input id="medical_facility" name="medical_facility" type="text" placeholder="Medical facility" required />
|
| 65 |
+
</label>
|
| 66 |
+
|
| 67 |
+
<label>
|
| 68 |
+
<span>Claim Amount</span>
|
| 69 |
+
<input id="claim_amount" name="claim_amount" type="number" min="0" step="0.01" placeholder="Claim amount" required />
|
| 70 |
+
</label>
|
| 71 |
+
|
| 72 |
+
<div class="upload-box full">
|
| 73 |
+
<div>
|
| 74 |
+
<strong>Make sure your payment receipt includes:</strong>
|
| 75 |
+
<ul>
|
| 76 |
+
<li>The patient's name</li>
|
| 77 |
+
<li>Treatment date</li>
|
| 78 |
+
<li>Medical facility</li>
|
| 79 |
+
<li>Total amount charged</li>
|
| 80 |
+
</ul>
|
| 81 |
+
</div>
|
| 82 |
+
<label class="file-control">
|
| 83 |
+
<input id="invoice" name="invoice" type="file" accept=".pdf,.png,.jpg,.jpeg" />
|
| 84 |
+
<span>Upload invoice PDF</span>
|
| 85 |
+
</label>
|
| 86 |
+
<p id="upload-status" class="upload-status"></p>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<button id="submit-btn" class="submit-btn" type="submit">Process Claim</button>
|
| 90 |
+
</form>
|
| 91 |
+
|
| 92 |
+
<section id="result" class="result-panel" hidden>
|
| 93 |
+
<div class="report">
|
| 94 |
+
<h2>Executive Summary</h2>
|
| 95 |
+
<p id="r-executive-summary"></p>
|
| 96 |
+
|
| 97 |
+
<h2>Introduction</h2>
|
| 98 |
+
<p id="r-introduction"></p>
|
| 99 |
+
|
| 100 |
+
<h2>Claim Details</h2>
|
| 101 |
+
<p><strong>Patient Name:</strong> <span id="r-name"></span></p>
|
| 102 |
+
<p><strong>Address:</strong> <span id="r-address"></span></p>
|
| 103 |
+
<p><strong>Claim Type:</strong> <span id="r-claim-item"></span></p>
|
| 104 |
+
<p><strong>Medical Facility:</strong> <span id="r-facility"></span></p>
|
| 105 |
+
<p><strong>Date:</strong> <span id="r-date"></span></p>
|
| 106 |
+
<p><strong>Total Claim Amount:</strong> <span id="r-amount"></span></p>
|
| 107 |
+
|
| 108 |
+
<h2>Claim Description</h2>
|
| 109 |
+
<p id="r-description"></p>
|
| 110 |
+
|
| 111 |
+
<h2>Document Verification</h2>
|
| 112 |
+
<p id="r-verification"></p>
|
| 113 |
+
|
| 114 |
+
<h2>Document Summary</h2>
|
| 115 |
+
<p id="r-summary"></p>
|
| 116 |
+
|
| 117 |
+
<h2>Conclusion</h2>
|
| 118 |
+
<p id="r-conclusion"></p>
|
| 119 |
+
|
| 120 |
+
<details class="policy-evidence">
|
| 121 |
+
<summary>Policy Evidence</summary>
|
| 122 |
+
<div id="r-citations" class="citations"></div>
|
| 123 |
+
</details>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<button id="back-btn" class="back-btn" type="button">Back</button>
|
| 127 |
+
</section>
|
| 128 |
+
</section>
|
| 129 |
+
</main>
|
| 130 |
+
|
| 131 |
+
<script src="/static/app.js?v=blank-claim-form-v1"></script>
|
| 132 |
+
</body>
|
| 133 |
+
</html>
|
frontend/styles.css
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--text: #1f2937;
|
| 3 |
+
--muted: #667085;
|
| 4 |
+
--line: #d9e0ea;
|
| 5 |
+
--panel: #ffffff;
|
| 6 |
+
--blue: #b9dbff;
|
| 7 |
+
--blue-strong: #386ccf;
|
| 8 |
+
--danger: #b42318;
|
| 9 |
+
--warning: #b54708;
|
| 10 |
+
--success: #067647;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
* {
|
| 14 |
+
box-sizing: border-box;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
body {
|
| 18 |
+
margin: 0;
|
| 19 |
+
min-height: 100vh;
|
| 20 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 21 |
+
color: var(--text);
|
| 22 |
+
background: linear-gradient(90deg, #f7fbff 0%, #eef5ff 34%, #ffffff 34%);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.app-shell {
|
| 26 |
+
width: min(1160px, calc(100vw - 32px));
|
| 27 |
+
margin: 70px auto 36px;
|
| 28 |
+
display: grid;
|
| 29 |
+
grid-template-columns: 340px minmax(0, 1fr);
|
| 30 |
+
gap: 28px;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.intro-pane {
|
| 34 |
+
min-height: 560px;
|
| 35 |
+
display: flex;
|
| 36 |
+
justify-content: center;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.intro-content {
|
| 40 |
+
width: 260px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
h1 {
|
| 44 |
+
margin: 0;
|
| 45 |
+
font-size: 24px;
|
| 46 |
+
line-height: 1.2;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.intro-content p {
|
| 50 |
+
margin: 8px 0 36px;
|
| 51 |
+
color: var(--muted);
|
| 52 |
+
font-size: 13px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.shield-art {
|
| 56 |
+
position: relative;
|
| 57 |
+
width: 220px;
|
| 58 |
+
height: 220px;
|
| 59 |
+
margin: 0 auto;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.shield {
|
| 63 |
+
position: absolute;
|
| 64 |
+
inset: 34px 44px 22px 18px;
|
| 65 |
+
background: linear-gradient(135deg, #dce8ff, #f9fbff);
|
| 66 |
+
border: 12px solid #567bd7;
|
| 67 |
+
border-right-color: #e9f0ff;
|
| 68 |
+
border-radius: 34px 34px 68px 68px;
|
| 69 |
+
transform: rotate(-24deg);
|
| 70 |
+
box-shadow: 0 20px 40px rgba(56, 108, 207, 0.18);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.key-ring {
|
| 74 |
+
position: absolute;
|
| 75 |
+
width: 50px;
|
| 76 |
+
height: 50px;
|
| 77 |
+
border: 14px solid #6a82df;
|
| 78 |
+
border-radius: 50%;
|
| 79 |
+
top: 60px;
|
| 80 |
+
left: 52px;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.key-stem {
|
| 84 |
+
position: absolute;
|
| 85 |
+
width: 78px;
|
| 86 |
+
height: 12px;
|
| 87 |
+
background: #6a82df;
|
| 88 |
+
top: 82px;
|
| 89 |
+
left: 96px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.key-bit {
|
| 93 |
+
position: absolute;
|
| 94 |
+
width: 30px;
|
| 95 |
+
height: 34px;
|
| 96 |
+
border: 10px solid #6a82df;
|
| 97 |
+
border-left: 0;
|
| 98 |
+
top: 71px;
|
| 99 |
+
left: 158px;
|
| 100 |
+
border-radius: 0 18px 18px 0;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.check-bubble {
|
| 104 |
+
position: absolute;
|
| 105 |
+
right: 28px;
|
| 106 |
+
top: 0;
|
| 107 |
+
width: 68px;
|
| 108 |
+
height: 54px;
|
| 109 |
+
border-radius: 50%;
|
| 110 |
+
background: #dfe8ff;
|
| 111 |
+
color: #4876d8;
|
| 112 |
+
font-size: 42px;
|
| 113 |
+
font-weight: 700;
|
| 114 |
+
text-align: center;
|
| 115 |
+
line-height: 54px;
|
| 116 |
+
box-shadow: 0 10px 28px rgba(70, 118, 216, 0.2);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.claim-pane {
|
| 120 |
+
background: var(--panel);
|
| 121 |
+
border: 1px solid #e5e9f0;
|
| 122 |
+
padding: 34px 34px 42px;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.claim-form {
|
| 126 |
+
display: grid;
|
| 127 |
+
grid-template-columns: 1fr 1fr;
|
| 128 |
+
gap: 22px 24px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
label {
|
| 132 |
+
display: flex;
|
| 133 |
+
flex-direction: column;
|
| 134 |
+
gap: 8px;
|
| 135 |
+
font-size: 13px;
|
| 136 |
+
font-weight: 600;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.full {
|
| 140 |
+
grid-column: 1 / -1;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
input,
|
| 144 |
+
select {
|
| 145 |
+
width: 100%;
|
| 146 |
+
height: 34px;
|
| 147 |
+
border: 1px solid #cfd7e3;
|
| 148 |
+
border-radius: 4px;
|
| 149 |
+
padding: 7px 9px;
|
| 150 |
+
font: inherit;
|
| 151 |
+
font-weight: 400;
|
| 152 |
+
color: var(--text);
|
| 153 |
+
background: #fff;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
input:focus,
|
| 157 |
+
select:focus {
|
| 158 |
+
outline: 2px solid rgba(56, 108, 207, 0.25);
|
| 159 |
+
border-color: #587fd7;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.upload-box {
|
| 163 |
+
background: var(--blue);
|
| 164 |
+
border-radius: 8px;
|
| 165 |
+
padding: 16px 18px;
|
| 166 |
+
display: grid;
|
| 167 |
+
grid-template-columns: minmax(0, 1fr) 210px;
|
| 168 |
+
gap: 16px;
|
| 169 |
+
align-items: center;
|
| 170 |
+
font-size: 13px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.upload-box ul {
|
| 174 |
+
margin: 8px 0 0;
|
| 175 |
+
padding-left: 20px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.file-control input {
|
| 179 |
+
display: none;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.file-control span,
|
| 183 |
+
.submit-btn {
|
| 184 |
+
min-height: 38px;
|
| 185 |
+
display: inline-flex;
|
| 186 |
+
align-items: center;
|
| 187 |
+
justify-content: center;
|
| 188 |
+
border: 0;
|
| 189 |
+
border-radius: 4px;
|
| 190 |
+
background: var(--blue-strong);
|
| 191 |
+
color: #fff;
|
| 192 |
+
font-weight: 700;
|
| 193 |
+
cursor: pointer;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.upload-status {
|
| 197 |
+
grid-column: 1 / -1;
|
| 198 |
+
min-height: 18px;
|
| 199 |
+
margin: 0;
|
| 200 |
+
color: #31527f;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.submit-btn {
|
| 204 |
+
width: 180px;
|
| 205 |
+
padding: 0 18px;
|
| 206 |
+
font-size: 14px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.result-panel {
|
| 210 |
+
margin: 34px auto 0;
|
| 211 |
+
border: 1px solid #cfd7e3;
|
| 212 |
+
padding: 20px 34px 30px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.report {
|
| 216 |
+
max-width: 560px;
|
| 217 |
+
margin: 0 auto;
|
| 218 |
+
text-align: center;
|
| 219 |
+
color: #555f6d;
|
| 220 |
+
line-height: 1.45;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.report p {
|
| 224 |
+
margin: 4px 0;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.report h2 {
|
| 228 |
+
margin: 28px 0 8px;
|
| 229 |
+
color: #667085;
|
| 230 |
+
font-size: 16px;
|
| 231 |
+
font-weight: 500;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.policy-evidence {
|
| 235 |
+
margin-top: 26px;
|
| 236 |
+
text-align: left;
|
| 237 |
+
font-size: 12px;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.policy-evidence summary {
|
| 241 |
+
width: max-content;
|
| 242 |
+
margin: 0 auto 10px;
|
| 243 |
+
color: #667085;
|
| 244 |
+
cursor: pointer;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.citations {
|
| 248 |
+
display: grid;
|
| 249 |
+
gap: 8px;
|
| 250 |
+
text-align: left;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.citation {
|
| 254 |
+
border: 1px solid #e1e6ee;
|
| 255 |
+
border-radius: 6px;
|
| 256 |
+
padding: 10px;
|
| 257 |
+
background: #fbfcff;
|
| 258 |
+
font-size: 12px;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.citation strong {
|
| 262 |
+
display: block;
|
| 263 |
+
color: #364152;
|
| 264 |
+
margin-bottom: 4px;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.back-btn {
|
| 268 |
+
display: flex;
|
| 269 |
+
align-items: center;
|
| 270 |
+
justify-content: center;
|
| 271 |
+
min-width: 54px;
|
| 272 |
+
height: 34px;
|
| 273 |
+
margin: 38px auto 0;
|
| 274 |
+
padding: 0 16px;
|
| 275 |
+
border: 0;
|
| 276 |
+
border-radius: 4px;
|
| 277 |
+
background: var(--blue-strong);
|
| 278 |
+
color: #fff;
|
| 279 |
+
font-weight: 700;
|
| 280 |
+
cursor: pointer;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
@media (max-width: 860px) {
|
| 284 |
+
body {
|
| 285 |
+
background: #fff;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.app-shell {
|
| 289 |
+
grid-template-columns: 1fr;
|
| 290 |
+
margin-top: 28px;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.intro-pane {
|
| 294 |
+
min-height: auto;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.claim-form,
|
| 298 |
+
.upload-box {
|
| 299 |
+
grid-template-columns: 1fr;
|
| 300 |
+
}
|
| 301 |
+
}
|
scripts/create_bangladesh_invoice.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import fitz
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def main() -> None:
|
| 7 |
+
output = Path("sample_invoice_bangladesh.pdf")
|
| 8 |
+
doc = fitz.open()
|
| 9 |
+
page = doc.new_page(width=595, height=842)
|
| 10 |
+
|
| 11 |
+
y = 78
|
| 12 |
+
page.insert_text((190, y), "DHAKA CENTRAL HOSPITAL", fontsize=16, fontname="helv")
|
| 13 |
+
y += 20
|
| 14 |
+
page.insert_text((205, y), "Original Hospital Invoice", fontsize=11, fontname="helv")
|
| 15 |
+
page.draw_line((72, 115), (523, 115), color=(0.55, 0.62, 0.72))
|
| 16 |
+
|
| 17 |
+
y = 145
|
| 18 |
+
lines = [
|
| 19 |
+
"Invoice Number: DCH-INV-2024-0718-204",
|
| 20 |
+
"Invoice Date: 18/07/2024",
|
| 21 |
+
"",
|
| 22 |
+
"Patient Information:",
|
| 23 |
+
"- Patient Name: Arafat Rahman",
|
| 24 |
+
"- Date of Birth: 14/03/1995",
|
| 25 |
+
"- Address: House 22, Road 7, Dhanmondi, Dhaka 1205, Bangladesh",
|
| 26 |
+
"- Phone Number: +8801712345678",
|
| 27 |
+
"",
|
| 28 |
+
"Service Details:",
|
| 29 |
+
"- Date of Treatment: 18/07/2024",
|
| 30 |
+
"- Medical Facility: DHAKA CENTRAL HOSPITAL",
|
| 31 |
+
"- Consultant: Dr Nusrat Jahan, General Physician",
|
| 32 |
+
"- Diagnosis: Acute viral fever",
|
| 33 |
+
"- Treatment Type: Outpatient consultation, CBC test, and short-term prescribed medicine",
|
| 34 |
+
"",
|
| 35 |
+
"Service Charges:",
|
| 36 |
+
"- Doctor's consultation fee: 1200",
|
| 37 |
+
"- CBC diagnostic test: 800",
|
| 38 |
+
"- Prescribed medicines: 1500",
|
| 39 |
+
"- Total charge: 3500",
|
| 40 |
+
"- Membership Discount: 5%",
|
| 41 |
+
"- Amount payable: 3325",
|
| 42 |
+
"",
|
| 43 |
+
"Payment Status: Paid by patient",
|
| 44 |
+
"Payment Method: Card",
|
| 45 |
+
"Receipt Status: Original unaltered invoice issued by DHAKA CENTRAL HOSPITAL.",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
for line in lines:
|
| 49 |
+
if line.endswith(":"):
|
| 50 |
+
y += 14
|
| 51 |
+
page.insert_text((72, y), line, fontsize=12, fontname="helv")
|
| 52 |
+
page.draw_line((72, y + 3), (72 + min(190, len(line) * 7), y + 3))
|
| 53 |
+
y += 24
|
| 54 |
+
continue
|
| 55 |
+
if not line:
|
| 56 |
+
y += 10
|
| 57 |
+
continue
|
| 58 |
+
page.insert_text((72, y), line, fontsize=10.5, fontname="helv")
|
| 59 |
+
y += 20
|
| 60 |
+
|
| 61 |
+
page.draw_line((72, 775), (523, 775), color=(0.75, 0.78, 0.82))
|
| 62 |
+
page.insert_text(
|
| 63 |
+
(72, 792),
|
| 64 |
+
"Sample Bangladesh invoice for AI Claims Processing System testing only.",
|
| 65 |
+
fontsize=8,
|
| 66 |
+
fontname="helv",
|
| 67 |
+
color=(0.35, 0.35, 0.35),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
doc.save(output)
|
| 71 |
+
doc.close()
|
| 72 |
+
print(output.resolve())
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
main()
|
scripts/create_complete_claim_pdf.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import fitz
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
OUTPUT = Path("complete_claim_package_surbhit.pdf")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def add_heading(page: fitz.Page, title: str) -> int:
|
| 10 |
+
page.insert_text((72, 58), title, fontsize=17, fontname="helv")
|
| 11 |
+
page.draw_line((72, 82), (523, 82), color=(0.55, 0.62, 0.72))
|
| 12 |
+
return 112
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def add_lines(page: fitz.Page, lines: list[str], y: int, size: int = 10) -> int:
|
| 16 |
+
for line in lines:
|
| 17 |
+
if not line:
|
| 18 |
+
y += 14
|
| 19 |
+
continue
|
| 20 |
+
page.insert_text((72, y), line, fontsize=size, fontname="helv")
|
| 21 |
+
y += 18
|
| 22 |
+
return y
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def add_footer(page: fitz.Page, page_no: int) -> None:
|
| 26 |
+
page.draw_line((72, 775), (523, 775), color=(0.75, 0.78, 0.82))
|
| 27 |
+
page.insert_text(
|
| 28 |
+
(72, 792),
|
| 29 |
+
f"Sample document for AI Claims Processing System testing only | Page {page_no}",
|
| 30 |
+
fontsize=8,
|
| 31 |
+
fontname="helv",
|
| 32 |
+
color=(0.35, 0.35, 0.35),
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main() -> None:
|
| 37 |
+
doc = fitz.open()
|
| 38 |
+
|
| 39 |
+
page = doc.new_page(width=595, height=842)
|
| 40 |
+
y = add_heading(page, "APOLLO HOSPITALS - ORIGINAL TAX INVOICE")
|
| 41 |
+
y = add_lines(
|
| 42 |
+
page,
|
| 43 |
+
[
|
| 44 |
+
"Invoice Number: AH-INV-2024-0202-115",
|
| 45 |
+
"Pre-authorisation Number: BUPA-PA-240202-7718",
|
| 46 |
+
"Invoice Date: 02/02/2024",
|
| 47 |
+
"",
|
| 48 |
+
"Patient Information:",
|
| 49 |
+
"- Patient Name: Surbhit",
|
| 50 |
+
"- Date of Birth: 01/01/1998",
|
| 51 |
+
"- Address: 22 Baker Street, London, United Kingdom",
|
| 52 |
+
"- Phone Number: +44 7700 900123",
|
| 53 |
+
"- Policy Number: BUPA-HLT-204455",
|
| 54 |
+
"",
|
| 55 |
+
"Service Details:",
|
| 56 |
+
"- Date of Treatment: 02/02/2024",
|
| 57 |
+
"- Medical Facility: APOLLO HOSPITALS",
|
| 58 |
+
"- Consultant: Dr Amelia Wright, General Practitioner",
|
| 59 |
+
"- Diagnosis: Acute tension headache",
|
| 60 |
+
"- Treatment Type: Outpatient consultation and short-term prescribed medicine",
|
| 61 |
+
"",
|
| 62 |
+
"Service Charges:",
|
| 63 |
+
"- Doctor's consultation fee: 1500",
|
| 64 |
+
"- Diagnostic observation and clinical assessment: 500",
|
| 65 |
+
"- Prescribed medicines: 1500",
|
| 66 |
+
"- Total charge: 3500",
|
| 67 |
+
"- Membership Discount: 10%",
|
| 68 |
+
"- Amount payable: 3150",
|
| 69 |
+
"",
|
| 70 |
+
"Payment Status: Paid by patient",
|
| 71 |
+
"Receipt Status: Original unaltered invoice issued by APOLLO HOSPITALS.",
|
| 72 |
+
],
|
| 73 |
+
y,
|
| 74 |
+
)
|
| 75 |
+
add_footer(page, 1)
|
| 76 |
+
|
| 77 |
+
page = doc.new_page(width=595, height=842)
|
| 78 |
+
y = add_heading(page, "PRESCRIPTION")
|
| 79 |
+
y = add_lines(
|
| 80 |
+
page,
|
| 81 |
+
[
|
| 82 |
+
"Prescription ID: RX-2024-0202-883",
|
| 83 |
+
"Date: 02/02/2024",
|
| 84 |
+
"Patient Name: Surbhit",
|
| 85 |
+
"Diagnosis: Acute tension headache",
|
| 86 |
+
"",
|
| 87 |
+
"Prescribed Medicines:",
|
| 88 |
+
"1. Paracetamol 500mg - Take one tablet every 6 hours if required for pain, maximum 3 days.",
|
| 89 |
+
"2. Ibuprofen 200mg - Take one tablet after food if required, maximum 2 days.",
|
| 90 |
+
"3. Oral rehydration solution - As required.",
|
| 91 |
+
"",
|
| 92 |
+
"Clinical Note:",
|
| 93 |
+
"Medication was prescribed for short-term symptom relief linked to the acute outpatient visit.",
|
| 94 |
+
"No long-term medicine, chronic disease management, or preventive screening was prescribed.",
|
| 95 |
+
"",
|
| 96 |
+
"Prescriber:",
|
| 97 |
+
"Dr Amelia Wright",
|
| 98 |
+
"GMC Number: 7123456",
|
| 99 |
+
"Signature: Dr Amelia Wright",
|
| 100 |
+
],
|
| 101 |
+
y,
|
| 102 |
+
)
|
| 103 |
+
add_footer(page, 2)
|
| 104 |
+
|
| 105 |
+
page = doc.new_page(width=595, height=842)
|
| 106 |
+
y = add_heading(page, "MEDICAL REPORT")
|
| 107 |
+
y = add_lines(
|
| 108 |
+
page,
|
| 109 |
+
[
|
| 110 |
+
"Report Number: MR-2024-0202-491",
|
| 111 |
+
"Date of Report: 02/02/2024",
|
| 112 |
+
"Patient Name: Surbhit",
|
| 113 |
+
"Date of Treatment: 02/02/2024",
|
| 114 |
+
"Treating Clinician: Dr Amelia Wright",
|
| 115 |
+
"",
|
| 116 |
+
"Presenting Complaint:",
|
| 117 |
+
"The patient attended outpatient consultation with headache symptoms that started the same day.",
|
| 118 |
+
"",
|
| 119 |
+
"Clinical Findings:",
|
| 120 |
+
"- No loss of consciousness reported.",
|
| 121 |
+
"- No neurological deficit observed during examination.",
|
| 122 |
+
"- Blood pressure and temperature were within normal limits.",
|
| 123 |
+
"- No evidence of chronic headache disorder was recorded.",
|
| 124 |
+
"",
|
| 125 |
+
"Diagnosis:",
|
| 126 |
+
"Acute tension headache.",
|
| 127 |
+
"",
|
| 128 |
+
"Treatment Provided:",
|
| 129 |
+
"Outpatient consultation, clinical assessment, advice on hydration/rest, and short-term medication.",
|
| 130 |
+
"",
|
| 131 |
+
"Medical Necessity Statement:",
|
| 132 |
+
"The consultation was medically necessary to assess acute symptoms and rule out urgent warning signs.",
|
| 133 |
+
"The treatment was short-term and intended to return the patient to their prior state of health.",
|
| 134 |
+
],
|
| 135 |
+
y,
|
| 136 |
+
)
|
| 137 |
+
add_footer(page, 3)
|
| 138 |
+
|
| 139 |
+
page = doc.new_page(width=595, height=842)
|
| 140 |
+
y = add_heading(page, "BUPA PRE-AUTHORISATION CONFIRMATION")
|
| 141 |
+
y = add_lines(
|
| 142 |
+
page,
|
| 143 |
+
[
|
| 144 |
+
"Pre-authorisation Number: BUPA-PA-240202-7718",
|
| 145 |
+
"Date Issued: 02/02/2024",
|
| 146 |
+
"Policy Number: BUPA-HLT-204455",
|
| 147 |
+
"Patient Name: Surbhit",
|
| 148 |
+
"",
|
| 149 |
+
"Authorised Service:",
|
| 150 |
+
"- Outpatient consultation for acute symptoms",
|
| 151 |
+
"- Consultant/GP assessment",
|
| 152 |
+
"- Clinically necessary short-term treatment linked to the consultation",
|
| 153 |
+
"",
|
| 154 |
+
"Facility and Clinician:",
|
| 155 |
+
"- Medical Facility: APOLLO HOSPITALS",
|
| 156 |
+
"- Clinician: Dr Amelia Wright",
|
| 157 |
+
"",
|
| 158 |
+
"Important Note:",
|
| 159 |
+
"This sample confirmation is provided for testing the AI workflow only.",
|
| 160 |
+
"Final payment remains subject to policy terms, membership certificate benefits, excess, allowances,",
|
| 161 |
+
"recognised provider status, and review of original documents.",
|
| 162 |
+
],
|
| 163 |
+
y,
|
| 164 |
+
)
|
| 165 |
+
add_footer(page, 4)
|
| 166 |
+
|
| 167 |
+
page = doc.new_page(width=595, height=842)
|
| 168 |
+
y = add_heading(page, "PAYMENT RECEIPT")
|
| 169 |
+
y = add_lines(
|
| 170 |
+
page,
|
| 171 |
+
[
|
| 172 |
+
"Receipt Number: PAY-2024-0202-3150",
|
| 173 |
+
"Invoice Number: AH-INV-2024-0202-115",
|
| 174 |
+
"Payment Date: 02/02/2024",
|
| 175 |
+
"Patient Name: Surbhit",
|
| 176 |
+
"Medical Facility: APOLLO HOSPITALS",
|
| 177 |
+
"",
|
| 178 |
+
"Payment Breakdown:",
|
| 179 |
+
"- Total charge: 3500",
|
| 180 |
+
"- Membership discount: 350",
|
| 181 |
+
"- Amount payable: 3150",
|
| 182 |
+
"- Amount paid: 3150",
|
| 183 |
+
"",
|
| 184 |
+
"Payment Method: Card",
|
| 185 |
+
"Payment Status: Paid in full",
|
| 186 |
+
"Issued by: APOLLO HOSPITALS Billing Department",
|
| 187 |
+
],
|
| 188 |
+
y,
|
| 189 |
+
)
|
| 190 |
+
add_footer(page, 5)
|
| 191 |
+
|
| 192 |
+
doc.save(OUTPUT)
|
| 193 |
+
doc.close()
|
| 194 |
+
print(OUTPUT.resolve())
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
main()
|
scripts/create_sample_invoice.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import fitz
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def main() -> None:
|
| 7 |
+
output = Path("sample_invoice_apollo.pdf")
|
| 8 |
+
doc = fitz.open()
|
| 9 |
+
page = doc.new_page(width=595, height=842)
|
| 10 |
+
|
| 11 |
+
y = 90
|
| 12 |
+
page.insert_text((230, y), "APOLLO HOSPITALS", fontsize=16, fontname="helv")
|
| 13 |
+
|
| 14 |
+
y += 45
|
| 15 |
+
page.insert_text((80, y), "Patient Information:", fontsize=12, fontname="helv")
|
| 16 |
+
page.draw_line((80, y + 3), (200, y + 3))
|
| 17 |
+
|
| 18 |
+
y += 35
|
| 19 |
+
lines = [
|
| 20 |
+
"- Name: Surbhit",
|
| 21 |
+
"- Date of Birth: 01/01/1998",
|
| 22 |
+
"- Address: India",
|
| 23 |
+
"- Phone Number: 1239874653",
|
| 24 |
+
"",
|
| 25 |
+
"Service Details:",
|
| 26 |
+
"Date of Service: 02/02/2024",
|
| 27 |
+
"Diagnosis: Headache",
|
| 28 |
+
"",
|
| 29 |
+
"Details: Consultation and medicines as mentioned in the prescription",
|
| 30 |
+
"",
|
| 31 |
+
"Service charges:",
|
| 32 |
+
"Doctor's fee - 1500",
|
| 33 |
+
"Medicines - 2000",
|
| 34 |
+
"",
|
| 35 |
+
"Total charge - 3500",
|
| 36 |
+
"Membership Discount - 10%",
|
| 37 |
+
"Amount payable - 3150",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
for line in lines:
|
| 41 |
+
if line == "Service Details:":
|
| 42 |
+
y += 18
|
| 43 |
+
page.insert_text((80, y), line, fontsize=12, fontname="helv")
|
| 44 |
+
page.draw_line((80, y + 3), (170, y + 3))
|
| 45 |
+
y += 24
|
| 46 |
+
continue
|
| 47 |
+
|
| 48 |
+
page.insert_text((80, y), line, fontsize=11, fontname="helv")
|
| 49 |
+
y += 22
|
| 50 |
+
|
| 51 |
+
page.insert_text(
|
| 52 |
+
(80, 760),
|
| 53 |
+
"This is a sample invoice for testing the AI Claims Processing System.",
|
| 54 |
+
fontsize=9,
|
| 55 |
+
fontname="helv",
|
| 56 |
+
color=(0.35, 0.35, 0.35),
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
doc.save(output)
|
| 60 |
+
doc.close()
|
| 61 |
+
print(output.resolve())
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
main()
|