Shoaib-33 commited on
Commit
da32bf1
·
1 Parent(s): 2046d88
.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("&", "&amp;")
161
+ .replaceAll("<", "&lt;")
162
+ .replaceAll(">", "&gt;")
163
+ .replaceAll('"', "&quot;")
164
+ .replaceAll("'", "&#039;");
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()