Shoaib-33 commited on
Commit
8058e7e
·
1 Parent(s): 9131703
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv
3
+ puku
4
+ __pycache__
5
+ *.pyc
6
+ .pytest_cache
7
+ .env
8
+ data/qdrant
9
+ data/copilot.db
10
+ data/bm25_index.json
11
+ data/uploads
12
+ *.log
.env.example ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME=Insurance Claims Copilot
2
+ APP_ENV=development
3
+ APP_HOST=127.0.0.1
4
+ APP_PORT=8000
5
+
6
+ GROQ_API_KEY=
7
+ GROQ_MODEL=llama-3.3-70b-versatile
8
+
9
+ QDRANT_URL=local:data/qdrant
10
+ QDRANT_API_KEY=
11
+ QDRANT_COLLECTION=insurance_claims
12
+ QDRANT_CACHE_COLLECTION=semantic_answer_cache
13
+
14
+ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
15
+ EMBEDDING_DIM=384
16
+
17
+ SQLITE_PATH=data/copilot.db
18
+ DOCUMENT_DIR=data
19
+ UPLOAD_DIR=data/uploads
20
+ BM25_INDEX_PATH=data/bm25_index.json
21
+ AUTO_INGEST_PDFS_ON_STARTUP=true
22
+
23
+ SEMANTIC_CACHE_THRESHOLD=0.88
24
+ SELF_RAG_MAX_LOOPS=2
25
+ RETRIEVAL_TOP_K=8
26
+ RERANK_TOP_K=5
27
+ LOW_LATENCY_MODE=true
28
+ ENABLE_QUERY_REWRITE=true
29
+ MAX_SOURCES_TO_LLM=5
30
+ MAX_EVIDENCE_CHARS_PER_SOURCE=900
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ puku/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ .pytest_cache/
7
+
8
+ data/qdrant/
9
+ data/copilot.db
10
+ data/bm25_index.json
11
+ data/uploads/
12
+ data/eval/ragas_results.json
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-bookworm
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PIP_NO_CACHE_DIR=1
6
+ ENV PIP_DEFAULT_TIMEOUT=120
7
+ ENV HF_HOME=/app/.cache/huggingface
8
+
9
+ WORKDIR /app
10
+
11
+ RUN apt-get update \
12
+ && apt-get install -y --no-install-recommends curl \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ COPY requirements-docker.txt .
16
+ RUN python -m pip install --upgrade pip \
17
+ && python -m pip install --index-url https://download.pytorch.org/whl/cpu torch \
18
+ && python -m pip install -r requirements-docker.txt
19
+
20
+ COPY app ./app
21
+ COPY frontend ./frontend
22
+ COPY scripts ./scripts
23
+ COPY data/sample_insurance_claim_guide.pdf ./data/sample_insurance_claim_guide.pdf
24
+ COPY .env.example ./.env.example
25
+
26
+ RUN mkdir -p /app/data/uploads /app/.cache/huggingface
27
+
28
+ EXPOSE 8000
29
+
30
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
31
+ CMD curl -fsS http://127.0.0.1:8000/api/health || exit 1
32
+
33
+ CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Md Shoaib Shahriar Ibrahim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Insurance claims copilot backend."""
app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API route package."""
app/api/routes_health.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter(tags=["health"])
4
+
5
+
6
+ @router.get("/health")
7
+ def health() -> dict[str, str]:
8
+ return {"status": "ok"}
app/api/routes_ingest.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
4
+ from pydantic import BaseModel, Field
5
+
6
+ from app.rag.ingestion import DocumentIngestionService
7
+
8
+ router = APIRouter(tags=["documents"])
9
+
10
+
11
+ class IngestTextRequest(BaseModel):
12
+ text: str = Field(min_length=1)
13
+ source_name: str = "manual_input"
14
+ metadata: dict[str, Any] | None = None
15
+
16
+
17
+ @router.post("/documents/ingest")
18
+ def ingest_text(payload: IngestTextRequest) -> dict[str, Any]:
19
+ return DocumentIngestionService().ingest_text(
20
+ text=payload.text,
21
+ source_name=payload.source_name,
22
+ metadata=payload.metadata,
23
+ )
24
+
25
+
26
+ @router.post("/documents/upload")
27
+ async def upload_document(
28
+ file: UploadFile = File(...),
29
+ policy_type: str = Form(default=""),
30
+ jurisdiction: str = Form(default=""),
31
+ ) -> dict[str, Any]:
32
+ raw = await file.read()
33
+ try:
34
+ text = raw.decode("utf-8")
35
+ except UnicodeDecodeError as exc:
36
+ raise HTTPException(status_code=400, detail="Only UTF-8 text files are supported in this scaffold.") from exc
37
+
38
+ metadata = {
39
+ "policy_type": policy_type,
40
+ "jurisdiction": jurisdiction,
41
+ }
42
+ return DocumentIngestionService().ingest_text(
43
+ text=text,
44
+ source_name=file.filename or "uploaded_document.txt",
45
+ metadata=metadata,
46
+ )
app/api/routes_query.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ from functools import lru_cache
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+ from pydantic import BaseModel, Field
8
+
9
+ from app.db.sqlite import db
10
+ from app.rag.graph import ClaimsRAGGraph
11
+
12
+ router = APIRouter(tags=["query"])
13
+
14
+
15
+ @lru_cache
16
+ def get_rag_graph() -> ClaimsRAGGraph:
17
+ return ClaimsRAGGraph()
18
+
19
+
20
+ class QueryRequest(BaseModel):
21
+ query: str = Field(min_length=1)
22
+ user_id: str = "default_user"
23
+ metadata_filter: dict[str, Any] | None = None
24
+
25
+
26
+ @router.post("/query")
27
+ def query_copilot(payload: QueryRequest) -> dict[str, Any]:
28
+ started = time.perf_counter()
29
+ try:
30
+ result = get_rag_graph().run(payload.query, payload.metadata_filter, user_id=payload.user_id)
31
+ latency_ms = round((time.perf_counter() - started) * 1000, 2)
32
+ raw_sources = result.get("reranked_sources") or result.get("sources", [])
33
+ sources = [
34
+ {
35
+ "source_name": source.get("source_name", "unknown"),
36
+ "text": source.get("text", ""),
37
+ "score": source.get("score", source.get("rrf_score", 0.0)),
38
+ "page": source.get("metadata", {}).get("page"),
39
+ }
40
+ for source in raw_sources
41
+ ]
42
+ return {
43
+ "request_id": result["request_id"],
44
+ "answer": result.get("answer", ""),
45
+ "confidence": result.get("confidence", 0.0),
46
+ "latency_ms": latency_ms,
47
+ "sources": sources,
48
+ "from_cache": result.get("cache_hit", False),
49
+ "retrieval_used": bool(result.get("should_retrieve", True)),
50
+ "self_rag": {
51
+ **result.get("self_rag", {}),
52
+ "iterations": result.get("iteration", 0),
53
+ },
54
+ "memory_used": result.get("memory_context", "") != "No prior memory for this user.",
55
+ "trace": result.get("trace", []),
56
+ }
57
+ except Exception as exc:
58
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
59
+
60
+
61
+ @router.get("/traces/{request_id}")
62
+ def get_trace(request_id: str) -> dict[str, Any]:
63
+ with db() as conn:
64
+ row = conn.execute("SELECT * FROM traces WHERE request_id = ?", (request_id,)).fetchone()
65
+ if not row:
66
+ raise HTTPException(status_code=404, detail="Trace not found")
67
+ return {
68
+ "request_id": row["request_id"],
69
+ "query": row["query"],
70
+ "trace": json.loads(row["trace_json"]),
71
+ "created_at": row["created_at"],
72
+ }
app/api/routes_review.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any
3
+
4
+ from fastapi import APIRouter, HTTPException
5
+ from pydantic import BaseModel, Field
6
+
7
+ from app.db.sqlite import db
8
+ from app.rag.graph import ClaimsRAGGraph
9
+ from app.rag.text import new_id
10
+
11
+ router = APIRouter(tags=["review"])
12
+
13
+
14
+ class ApproveRequest(BaseModel):
15
+ request_id: str
16
+ original_answer: str
17
+ approved_answer: str
18
+ reviewer: str = "human_adjuster"
19
+
20
+
21
+ class RegenerateRequest(BaseModel):
22
+ query: str = Field(min_length=1)
23
+ metadata_filter: dict[str, Any] | None = None
24
+
25
+
26
+ @router.post("/review/approve")
27
+ def approve(payload: ApproveRequest) -> dict[str, Any]:
28
+ review_id = new_id("review")
29
+ with db() as conn:
30
+ conn.execute(
31
+ """
32
+ INSERT INTO reviews(review_id, request_id, original_answer, approved_answer, reviewer, status)
33
+ VALUES (?, ?, ?, ?, ?, ?)
34
+ """,
35
+ (
36
+ review_id,
37
+ payload.request_id,
38
+ payload.original_answer,
39
+ payload.approved_answer,
40
+ payload.reviewer,
41
+ "approved",
42
+ ),
43
+ )
44
+ return {"status": "approved", "review_id": review_id}
45
+
46
+
47
+ @router.post("/review/regenerate")
48
+ def regenerate(payload: RegenerateRequest) -> dict[str, Any]:
49
+ result = ClaimsRAGGraph().run(payload.query, payload.metadata_filter)
50
+ return {
51
+ "request_id": result["request_id"],
52
+ "answer": result.get("answer", ""),
53
+ "confidence": result.get("confidence", 0.0),
54
+ "sources": result.get("reranked_sources") or result.get("sources", []),
55
+ "self_rag": result.get("self_rag", {}),
56
+ }
57
+
58
+
59
+ @router.get("/claims/{claim_id}")
60
+ def get_claim(claim_id: str) -> dict[str, Any]:
61
+ return {
62
+ "claim_id": claim_id,
63
+ "customer": "Sample Customer",
64
+ "status": "Needs Review",
65
+ "loss_type": "Property",
66
+ "summary": "No live claims system is connected yet. This placeholder is ready for plan lookup integration.",
67
+ }
68
+
69
+
70
+ @router.get("/reviews")
71
+ def list_reviews() -> dict[str, Any]:
72
+ with db() as conn:
73
+ rows = conn.execute("SELECT * FROM reviews ORDER BY created_at DESC LIMIT 25").fetchall()
74
+ return {
75
+ "reviews": [
76
+ {
77
+ "review_id": row["review_id"],
78
+ "request_id": row["request_id"],
79
+ "approved_answer": row["approved_answer"],
80
+ "reviewer": row["reviewer"],
81
+ "status": row["status"],
82
+ "created_at": row["created_at"],
83
+ }
84
+ for row in rows
85
+ ]
86
+ }
app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core application services."""
app/core/config.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ app_name: str = "Insurance Claims Copilot"
9
+ app_env: str = "development"
10
+ app_host: str = "127.0.0.1"
11
+ app_port: int = 8000
12
+
13
+ groq_api_key: str = ""
14
+ groq_model: str = "llama-3.3-70b-versatile"
15
+
16
+ qdrant_url: str = "local:data/qdrant"
17
+ qdrant_api_key: str = ""
18
+ qdrant_collection: str = "insurance_claims"
19
+ qdrant_cache_collection: str = "semantic_answer_cache"
20
+
21
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
22
+ embedding_dim: int = 384
23
+
24
+ sqlite_path: str = "data/copilot.db"
25
+ document_dir: str = "data"
26
+ upload_dir: str = "data/uploads"
27
+ bm25_index_path: str = "data/bm25_index.json"
28
+ auto_ingest_pdfs_on_startup: bool = True
29
+
30
+ semantic_cache_threshold: float = 0.88
31
+ self_rag_max_loops: int = 2
32
+ retrieval_top_k: int = 8
33
+ rerank_top_k: int = 5
34
+ low_latency_mode: bool = True
35
+ enable_query_rewrite: bool = True
36
+ max_sources_to_llm: int = 5
37
+ max_evidence_chars_per_source: int = 900
38
+
39
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
40
+
41
+ def ensure_directories(self) -> None:
42
+ Path(self.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
43
+ Path(self.document_dir).mkdir(parents=True, exist_ok=True)
44
+ Path(self.upload_dir).mkdir(parents=True, exist_ok=True)
45
+ Path(self.bm25_index_path).parent.mkdir(parents=True, exist_ok=True)
46
+
47
+
48
+ @lru_cache
49
+ def get_settings() -> Settings:
50
+ return Settings()
51
+
52
+
53
+ settings = get_settings()
app/core/logging.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+
4
+ def configure_logging() -> None:
5
+ logging.basicConfig(
6
+ level=logging.INFO,
7
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
8
+ )
app/db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Database package."""
app/db/sqlite.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+ from typing import Iterator
5
+
6
+ from app.core.config import settings
7
+
8
+
9
+ SCHEMA = """
10
+ PRAGMA journal_mode=WAL;
11
+
12
+ CREATE TABLE IF NOT EXISTS documents (
13
+ doc_id TEXT PRIMARY KEY,
14
+ source_name TEXT NOT NULL,
15
+ file_hash TEXT NOT NULL,
16
+ normalized_hash TEXT NOT NULL,
17
+ simhash TEXT NOT NULL,
18
+ status TEXT NOT NULL,
19
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS chunks (
23
+ chunk_id TEXT PRIMARY KEY,
24
+ doc_id TEXT NOT NULL,
25
+ chunk_index INTEGER NOT NULL,
26
+ text TEXT NOT NULL,
27
+ text_hash TEXT NOT NULL,
28
+ metadata_json TEXT NOT NULL,
29
+ embedded_at TEXT,
30
+ FOREIGN KEY(doc_id) REFERENCES documents(doc_id)
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS answer_cache (
34
+ cache_id TEXT PRIMARY KEY,
35
+ query TEXT NOT NULL,
36
+ normalized_query TEXT NOT NULL,
37
+ answer TEXT NOT NULL,
38
+ confidence REAL NOT NULL,
39
+ sources_json TEXT NOT NULL,
40
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS traces (
44
+ request_id TEXT PRIMARY KEY,
45
+ query TEXT NOT NULL,
46
+ trace_json TEXT NOT NULL,
47
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS reviews (
51
+ review_id TEXT PRIMARY KEY,
52
+ request_id TEXT NOT NULL,
53
+ original_answer TEXT NOT NULL,
54
+ approved_answer TEXT NOT NULL,
55
+ reviewer TEXT NOT NULL,
56
+ status TEXT NOT NULL,
57
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS memories (
61
+ memory_id TEXT PRIMARY KEY,
62
+ user_id TEXT NOT NULL,
63
+ kind TEXT NOT NULL,
64
+ content TEXT NOT NULL,
65
+ metadata_json TEXT NOT NULL,
66
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS customers (
70
+ customer_id TEXT PRIMARY KEY,
71
+ name TEXT NOT NULL,
72
+ preferred_contact TEXT NOT NULL,
73
+ risk_notes TEXT NOT NULL
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS policies (
77
+ policy_id TEXT PRIMARY KEY,
78
+ customer_id TEXT NOT NULL,
79
+ policy_type TEXT NOT NULL,
80
+ active INTEGER NOT NULL,
81
+ coverages_json TEXT NOT NULL,
82
+ exclusions_json TEXT NOT NULL,
83
+ deductible REAL NOT NULL,
84
+ policy_limit REAL NOT NULL,
85
+ endorsements_json TEXT NOT NULL,
86
+ FOREIGN KEY(customer_id) REFERENCES customers(customer_id)
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS claims (
90
+ claim_id TEXT PRIMARY KEY,
91
+ customer_id TEXT NOT NULL,
92
+ policy_id TEXT NOT NULL,
93
+ claim_type TEXT NOT NULL,
94
+ status TEXT NOT NULL,
95
+ date_of_loss TEXT NOT NULL,
96
+ missing_documents_json TEXT NOT NULL,
97
+ notes TEXT NOT NULL,
98
+ FOREIGN KEY(customer_id) REFERENCES customers(customer_id),
99
+ FOREIGN KEY(policy_id) REFERENCES policies(policy_id)
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS ticket_queues (
103
+ queue_name TEXT PRIMARY KEY,
104
+ open_tickets INTEGER NOT NULL,
105
+ estimated_review_time TEXT NOT NULL
106
+ );
107
+
108
+ CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(file_hash, normalized_hash);
109
+ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(text_hash);
110
+ CREATE INDEX IF NOT EXISTS idx_memories_user_kind ON memories(user_id, kind, created_at);
111
+ """
112
+
113
+
114
+ SEED_SQL = """
115
+ INSERT OR IGNORE INTO customers(customer_id, name, preferred_contact, risk_notes)
116
+ VALUES
117
+ ('CUS-1001', 'Maya Rahman', 'email', 'Prior water claim required mitigation documentation.'),
118
+ ('CUS-1002', 'Omar Khan', 'phone', 'No special risk notes.'),
119
+ ('CUS-1003', 'Nadia Islam', 'email', 'Previously submitted theft claim with missing receipts.');
120
+
121
+ INSERT OR IGNORE INTO policies(policy_id, customer_id, policy_type, active, coverages_json, exclusions_json, deductible, policy_limit, endorsements_json)
122
+ VALUES
123
+ ('POL-3001', 'CUS-1001', 'property', 1, '["sudden accidental water discharge", "fire and smoke", "wind and hail", "theft of personal property"]', '["gradual leakage", "mold from long-term seepage", "flood without endorsement", "wear and tear"]', 1000, 25000, '["limited sewer backup"]'),
124
+ ('POL-3002', 'CUS-1002', 'auto', 1, '["collision", "comprehensive theft", "liability"]', '["intentional damage", "unlisted commercial use"]', 500, 40000, '[]'),
125
+ ('POL-3003', 'CUS-1003', 'property', 1, '["fire and smoke", "theft of personal property", "wind and hail"]', '["flood", "gradual leakage", "high-value unscheduled jewelry above sublimit"]', 1500, 50000, '["scheduled jewelry required above sublimit"]');
126
+
127
+ INSERT OR IGNORE INTO claims(claim_id, customer_id, policy_id, claim_type, status, date_of_loss, missing_documents_json, notes)
128
+ VALUES
129
+ ('CLM-1007', 'CUS-1001', 'POL-3001', 'water_damage', 'documents_pending', '2026-05-10', '["mitigation invoice", "repair estimate"]', 'Kitchen burst pipe; photos and plumber report received.'),
130
+ ('CLM-2011', 'CUS-1003', 'POL-3003', 'theft', 'human_review', '2026-04-28', '["receipts", "serial numbers"]', 'Laptop and camera stolen from vehicle; police report received.'),
131
+ ('CLM-3020', 'CUS-1001', 'POL-3001', 'storm', 'new', '2026-05-12', '["contractor estimate", "weather event confirmation"]', 'Roof hail damage reported.');
132
+
133
+ INSERT OR IGNORE INTO ticket_queues(queue_name, open_tickets, estimated_review_time)
134
+ VALUES
135
+ ('property_claims', 14, '1 business day'),
136
+ ('auto_claims', 8, 'same day'),
137
+ ('special_investigation', 6, '2 business days'),
138
+ ('liability_claims', 11, '1-2 business days');
139
+ """
140
+
141
+
142
+ def connect() -> sqlite3.Connection:
143
+ Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
144
+ conn = sqlite3.connect(settings.sqlite_path)
145
+ conn.row_factory = sqlite3.Row
146
+ return conn
147
+
148
+
149
+ @contextmanager
150
+ def db() -> Iterator[sqlite3.Connection]:
151
+ conn = connect()
152
+ try:
153
+ yield conn
154
+ conn.commit()
155
+ finally:
156
+ conn.close()
157
+
158
+
159
+ def init_db() -> None:
160
+ with db() as conn:
161
+ conn.executescript(SCHEMA)
162
+ conn.executescript(SEED_SQL)
app/guardrails/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Guardrail package."""
app/guardrails/pii.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class GuardrailResult:
7
+ text: str
8
+ blocked: bool
9
+ findings: list[str]
10
+
11
+
12
+ def build_langchain_pii_middlewares() -> list[object]:
13
+ """Create LangChain PII middleware objects for agent-compatible deployments."""
14
+ try:
15
+ from langchain.agents.middleware import PIIMiddleware
16
+
17
+ return [
18
+ PIIMiddleware("email", strategy="redact", apply_to_input=True, apply_to_output=True),
19
+ PIIMiddleware("credit_card", strategy="mask", apply_to_input=True, apply_to_output=True),
20
+ PIIMiddleware("ip", strategy="redact", apply_to_input=True, apply_to_output=True),
21
+ PIIMiddleware(
22
+ "policy_number",
23
+ detector=r"\b(?:POL[-\s]?(?=[A-Z0-9-]*\d)[A-Z0-9]{5,}|Policy[-\s]*(?:ID|No\.?|Number)[-:\s]*[A-Z0-9]*\d[A-Z0-9-]{4,})\b",
24
+ strategy="hash",
25
+ apply_to_input=True,
26
+ apply_to_output=True,
27
+ ),
28
+ PIIMiddleware(
29
+ "claim_id",
30
+ detector=r"\b(?:CLM[-\s]?(?=[A-Z0-9-]*\d)[A-Z0-9]{5,}|Claim[-\s]?(?:ID|No\.?|Number)?[-\s]*[A-Z0-9]*\d[A-Z0-9-]{4,})\b",
31
+ strategy="hash",
32
+ apply_to_input=True,
33
+ apply_to_output=True,
34
+ ),
35
+ ]
36
+ except Exception:
37
+ return []
38
+
39
+
40
+ class PIIGuardrails:
41
+ def __init__(self) -> None:
42
+ self.langchain_middlewares = build_langchain_pii_middlewares()
43
+ self.patterns: list[tuple[str, re.Pattern[str], str]] = [
44
+ ("email", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I), "[REDACTED_EMAIL]"),
45
+ ("credit_card", re.compile(r"\b(?:\d[ -]*?){13,19}\b"), "[MASKED_CARD]"),
46
+ ("phone", re.compile(r"\b(?:\+?\d{1,3}[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}\b"), "[REDACTED_PHONE]"),
47
+ (
48
+ "policy_number",
49
+ re.compile(r"\b(?:POL[-\s]?(?=[A-Z0-9-]*\d)[A-Z0-9]{5,}|Policy[-\s]*(?:ID|No\.?|Number)[-:\s]*[A-Z0-9]*\d[A-Z0-9-]{4,})\b", re.I),
50
+ "[HASHED_POLICY_NUMBER]",
51
+ ),
52
+ (
53
+ "claim_id",
54
+ re.compile(r"\b(?:CLM[-\s]?(?=[A-Z0-9-]*\d)[A-Z0-9]{5,}|Claim[-\s]?(?:ID|No\.?|Number)?[-\s]*[A-Z0-9]*\d[A-Z0-9-]{4,})\b", re.I),
55
+ "[HASHED_CLAIM_ID]",
56
+ ),
57
+ ]
58
+
59
+ def sanitize(self, text: str) -> GuardrailResult:
60
+ findings: list[str] = []
61
+ sanitized = text
62
+ for name, pattern, replacement in self.patterns:
63
+ if pattern.search(sanitized):
64
+ findings.append(name)
65
+ sanitized = pattern.sub(replacement, sanitized)
66
+ return GuardrailResult(text=sanitized, blocked=False, findings=findings)
67
+
68
+ def clean_legacy_false_positive_placeholders(self, text: str) -> str:
69
+ """Repair old cached answers where normal policy words were over-masked."""
70
+ cleaned = text
71
+ replacements = [
72
+ (r"standard insurance \[HASHED_POLICY_NUMBER\]", "standard insurance policy"),
73
+ (r"insurance \[HASHED_POLICY_NUMBER\]", "insurance policy"),
74
+ (r"\b[Tt]he \[HASHED_POLICY_NUMBER\]'s", "the policyholder's"),
75
+ (r"\b[Tt]he \[HASHED_POLICY_NUMBER\]", "the policyholder"),
76
+ (r"\[HASHED_POLICY_NUMBER\]'s specific policy documents", "the policyholder's specific policy documents"),
77
+ (r"\[HASHED_POLICY_NUMBER\] documents", "policy documents"),
78
+ (r"\[HASHED_POLICY_NUMBER\] or endorsements", "policy documents or endorsements"),
79
+ ]
80
+ for pattern, replacement in replacements:
81
+ cleaned = re.sub(pattern, replacement, cleaned)
82
+ return cleaned
app/guardrails/prompt_injection.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+
4
+ INJECTION_PATTERNS = [
5
+ r"ignore\s+(all\s+)?previous\s+instructions",
6
+ r"reveal\s+(the\s+)?system\s+prompt",
7
+ r"developer\s+message",
8
+ r"act\s+as\s+dan",
9
+ r"jailbreak",
10
+ ]
11
+
12
+
13
+ def detect_prompt_injection(text: str) -> list[str]:
14
+ findings = []
15
+ for pattern in INJECTION_PATTERNS:
16
+ if re.search(pattern, text, flags=re.I):
17
+ findings.append(pattern)
18
+ return findings
19
+
20
+
21
+ def strip_unsafe_retrieved_text(text: str) -> str:
22
+ cleaned = text
23
+ for pattern in INJECTION_PATTERNS:
24
+ cleaned = re.sub(pattern, "[REMOVED_PROMPT_INJECTION]", cleaned, flags=re.I)
25
+ return cleaned
app/main.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.responses import FileResponse
7
+
8
+ from app.api.routes_health import router as health_router
9
+ from app.api.routes_ingest import router as ingest_router
10
+ from app.api.routes_query import router as query_router
11
+ from app.api.routes_review import router as review_router
12
+ from app.core.config import settings
13
+ from app.core.logging import configure_logging
14
+ from app.db.sqlite import init_db
15
+ from app.rag.bm25 import BM25Index
16
+ from app.rag.ingestion import DocumentIngestionService
17
+ from app.rag.qdrant_store import QdrantVectorStore
18
+
19
+
20
+ configure_logging()
21
+
22
+ app = FastAPI(title=settings.app_name)
23
+
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ app.include_router(health_router, prefix="/api")
33
+ app.include_router(query_router, prefix="/api")
34
+ app.include_router(ingest_router, prefix="/api")
35
+ app.include_router(review_router, prefix="/api")
36
+
37
+ frontend_dir = Path(__file__).resolve().parent.parent / "frontend"
38
+ assets_dir = frontend_dir / "assets"
39
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
40
+
41
+
42
+ @app.on_event("startup")
43
+ def startup() -> None:
44
+ settings.ensure_directories()
45
+ init_db()
46
+ QdrantVectorStore().ensure_collections()
47
+ if settings.auto_ingest_pdfs_on_startup:
48
+ DocumentIngestionService().ingest_pdf_directory(settings.document_dir)
49
+ BM25Index.load_or_create().save()
50
+
51
+
52
+ @app.get("/")
53
+ def index() -> FileResponse:
54
+ return FileResponse(frontend_dir / "index.html")
app/rag/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """RAG pipeline package."""
app/rag/bm25.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from rank_bm25 import BM25Okapi
6
+
7
+ from app.core.config import settings
8
+ from app.db.sqlite import db
9
+ from app.rag.text import tokenize
10
+
11
+
12
+ class BM25Index:
13
+ def __init__(self, docs: list[dict[str, Any]]) -> None:
14
+ self.docs = docs
15
+ self.tokens = [tokenize(d["text"]) for d in docs]
16
+ self.index = BM25Okapi(self.tokens) if self.tokens else None
17
+
18
+ @classmethod
19
+ def from_db(cls) -> "BM25Index":
20
+ with db() as conn:
21
+ rows = conn.execute(
22
+ """
23
+ SELECT c.chunk_id, c.text, c.metadata_json, d.source_name
24
+ FROM chunks c
25
+ JOIN documents d ON d.doc_id = c.doc_id
26
+ """
27
+ ).fetchall()
28
+ docs = [
29
+ {
30
+ "id": row["chunk_id"],
31
+ "text": row["text"],
32
+ "source_name": row["source_name"],
33
+ "metadata": json.loads(row["metadata_json"]),
34
+ }
35
+ for row in rows
36
+ ]
37
+ return cls(docs)
38
+
39
+ @classmethod
40
+ def load_or_create(cls) -> "BM25Index":
41
+ path = Path(settings.bm25_index_path)
42
+ if not path.exists():
43
+ return cls.from_db()
44
+ try:
45
+ payload = json.loads(path.read_text(encoding="utf-8"))
46
+ return cls(payload.get("docs", []))
47
+ except (OSError, json.JSONDecodeError):
48
+ return cls.from_db()
49
+
50
+ def save(self) -> None:
51
+ path = Path(settings.bm25_index_path)
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ path.write_text(json.dumps({"docs": self.docs}, ensure_ascii=True), encoding="utf-8")
54
+
55
+ def rebuild(self) -> None:
56
+ fresh = self.from_db()
57
+ self.docs = fresh.docs
58
+ self.tokens = fresh.tokens
59
+ self.index = fresh.index
60
+ self.save()
61
+
62
+ def search(self, query: str, top_k: int) -> list[dict[str, Any]]:
63
+ if not self.index or not self.docs:
64
+ return []
65
+ scores = self.index.get_scores(tokenize(query))
66
+ ranked = sorted(enumerate(scores), key=lambda item: item[1], reverse=True)[:top_k]
67
+ return [
68
+ {
69
+ **self.docs[idx],
70
+ "score": float(score),
71
+ "metadata": {**self.docs[idx].get("metadata", {}), "retriever": "bm25"},
72
+ }
73
+ for idx, score in ranked
74
+ if score > 0
75
+ ]
app/rag/document_cache.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from dataclasses import dataclass
3
+
4
+ from app.db.sqlite import db
5
+ from app.rag.text import hamming_distance, normalize_text, sha256_text, simhash
6
+
7
+
8
+ @dataclass
9
+ class DocumentCacheDecision:
10
+ should_embed: bool
11
+ status: str
12
+ matched_doc_id: str | None = None
13
+ reason: str = ""
14
+
15
+
16
+ class DocumentCache:
17
+ near_duplicate_hamming_threshold = 4
18
+
19
+ def inspect(self, raw_text: str) -> DocumentCacheDecision:
20
+ normalized = normalize_text(raw_text)
21
+ file_hash = sha256_text(raw_text)
22
+ normalized_hash = sha256_text(normalized)
23
+ signature = simhash(normalized)
24
+
25
+ with db() as conn:
26
+ exact = conn.execute(
27
+ "SELECT doc_id FROM documents WHERE file_hash = ? OR normalized_hash = ?",
28
+ (file_hash, normalized_hash),
29
+ ).fetchone()
30
+ if exact:
31
+ return DocumentCacheDecision(
32
+ should_embed=False,
33
+ status="skipped_exact_duplicate",
34
+ matched_doc_id=exact["doc_id"],
35
+ reason="Document hash already exists.",
36
+ )
37
+
38
+ rows = conn.execute("SELECT doc_id, simhash FROM documents").fetchall()
39
+ for row in rows:
40
+ distance = hamming_distance(signature, int(row["simhash"]))
41
+ if distance <= self.near_duplicate_hamming_threshold:
42
+ return DocumentCacheDecision(
43
+ should_embed=False,
44
+ status="skipped_near_duplicate",
45
+ matched_doc_id=row["doc_id"],
46
+ reason=f"Near-duplicate SimHash distance {distance}.",
47
+ )
48
+
49
+ return DocumentCacheDecision(should_embed=True, status="new_document")
50
+
51
+ def chunk_exists(self, text_hash: str) -> bool:
52
+ with db() as conn:
53
+ row = conn.execute("SELECT chunk_id FROM chunks WHERE text_hash = ?", (text_hash,)).fetchone()
54
+ return row is not None
55
+
56
+ def save_document(
57
+ self,
58
+ doc_id: str,
59
+ source_name: str,
60
+ raw_text: str,
61
+ status: str,
62
+ ) -> None:
63
+ normalized = normalize_text(raw_text)
64
+ with db() as conn:
65
+ conn.execute(
66
+ """
67
+ INSERT INTO documents(doc_id, source_name, file_hash, normalized_hash, simhash, status)
68
+ VALUES (?, ?, ?, ?, ?, ?)
69
+ """,
70
+ (
71
+ doc_id,
72
+ source_name,
73
+ sha256_text(raw_text),
74
+ sha256_text(normalized),
75
+ str(simhash(normalized)),
76
+ status,
77
+ ),
78
+ )
79
+
80
+ def save_chunk(
81
+ self,
82
+ chunk_id: str,
83
+ doc_id: str,
84
+ chunk_index: int,
85
+ text: str,
86
+ text_hash: str,
87
+ metadata: dict,
88
+ embedded: bool,
89
+ ) -> None:
90
+ with db() as conn:
91
+ conn.execute(
92
+ """
93
+ INSERT OR IGNORE INTO chunks(chunk_id, doc_id, chunk_index, text, text_hash, metadata_json, embedded_at)
94
+ VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END)
95
+ """,
96
+ (chunk_id, doc_id, chunk_index, text, text_hash, json.dumps(metadata), int(embedded)),
97
+ )
app/rag/embeddings.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+
3
+ from app.core.config import settings
4
+
5
+
6
+ class EmbeddingModel:
7
+ def __init__(self) -> None:
8
+ from langchain_huggingface import HuggingFaceEmbeddings
9
+
10
+ self.model = HuggingFaceEmbeddings(
11
+ model_name=settings.embedding_model,
12
+ encode_kwargs={"normalize_embeddings": True},
13
+ )
14
+
15
+ def embed_query(self, text: str) -> list[float]:
16
+ return self.model.embed_query(text)
17
+
18
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
19
+ if not texts:
20
+ return []
21
+ return self.model.embed_documents(texts)
22
+
23
+
24
+ @lru_cache
25
+ def get_embedding_model() -> EmbeddingModel:
26
+ return EmbeddingModel()
app/rag/graph.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+ from langgraph.graph import END, StateGraph
6
+
7
+ from app.core.config import settings
8
+ from app.db.sqlite import db
9
+ from app.guardrails.pii import PIIGuardrails
10
+ from app.guardrails.prompt_injection import detect_prompt_injection, strip_unsafe_retrieved_text
11
+ from app.rag.hybrid_retriever import HybridRetriever
12
+ from app.rag.reranker import FlashRankReranker
13
+ from app.rag.semantic_cache import SemanticAnswerCache
14
+ from app.rag.self_rag import SelfRAG
15
+ from app.rag.state import RagState
16
+ from app.rag.text import new_id, normalize_text
17
+ from app.services.groq_llm import GroqLLM
18
+ from app.services.memory import ClaimMemoryService
19
+
20
+
21
+ class ClaimsRAGGraph:
22
+ def __init__(self) -> None:
23
+ self.cache = SemanticAnswerCache()
24
+ self.guardrails = PIIGuardrails()
25
+ self.retriever = HybridRetriever()
26
+ self.reranker = FlashRankReranker()
27
+ self.self_rag = SelfRAG()
28
+ self.llm = GroqLLM()
29
+ self.memory = ClaimMemoryService()
30
+ self.graph = self._build_graph()
31
+
32
+ def run(
33
+ self,
34
+ query: str,
35
+ metadata_filter: dict[str, Any] | None = None,
36
+ user_id: str = "default_user",
37
+ use_cache: bool = True,
38
+ ) -> RagState:
39
+ request_id = new_id("request")
40
+ state: RagState = {
41
+ "request_id": request_id,
42
+ "query": query,
43
+ "user_id": user_id,
44
+ "sanitized_query": query,
45
+ "retrieval_query": query,
46
+ "normalized_query": normalize_text(query),
47
+ "memory_context": "",
48
+ "iteration": 0,
49
+ "cache_hit": False,
50
+ "use_cache": use_cache,
51
+ "trace": [{"node": "start", "query": query, "metadata_filter": metadata_filter or {}}],
52
+ }
53
+ if metadata_filter:
54
+ state["metadata_filter"] = metadata_filter # type: ignore[typeddict-unknown-key]
55
+ result = self.graph.invoke(state)
56
+ self._save_trace(result)
57
+ return result
58
+
59
+ def _build_graph(self):
60
+ workflow = StateGraph(RagState)
61
+ workflow.add_node("planner", self._planner)
62
+ workflow.add_node("semantic_cache", self._semantic_cache)
63
+ workflow.add_node("guardrails", self._guardrails)
64
+ workflow.add_node("load_memory", self._load_memory)
65
+ workflow.add_node("direct_response", self._direct_response)
66
+ workflow.add_node("retrieve", self._retrieve)
67
+ workflow.add_node("rerank", self._rerank)
68
+ workflow.add_node("generate", self._generate)
69
+ workflow.add_node("critique", self._critique)
70
+ workflow.add_node("rewrite", self._rewrite)
71
+ workflow.add_node("finalize", self._finalize)
72
+
73
+ workflow.set_entry_point("planner")
74
+ workflow.add_edge("planner", "semantic_cache")
75
+ workflow.add_conditional_edges(
76
+ "semantic_cache",
77
+ self._route_cache,
78
+ {"hit": "finalize", "miss": "guardrails"},
79
+ )
80
+ workflow.add_conditional_edges(
81
+ "guardrails",
82
+ lambda state: "direct" if state.get("answer") and "override system" in state["answer"] else "memory",
83
+ {"direct": "direct_response", "memory": "load_memory"},
84
+ )
85
+ workflow.add_conditional_edges(
86
+ "load_memory",
87
+ self._route_retrieval,
88
+ {"direct": "direct_response", "retrieve": "retrieve"},
89
+ )
90
+ workflow.add_edge("direct_response", "finalize")
91
+ workflow.add_edge("retrieve", "rerank")
92
+ workflow.add_edge("rerank", "generate")
93
+ workflow.add_edge("generate", "critique")
94
+ workflow.add_conditional_edges(
95
+ "critique",
96
+ self._route_critique,
97
+ {"accept": "finalize", "retry": "rewrite"},
98
+ )
99
+ workflow.add_edge("rewrite", "retrieve")
100
+ workflow.add_edge("finalize", END)
101
+ return workflow.compile()
102
+
103
+ def _planner(self, state: RagState) -> RagState:
104
+ decision = self.self_rag.grade_retrieval_need(state["query"])
105
+ state["should_retrieve"] = bool(decision.get("should_retrieve", True))
106
+ state["intent"] = str(decision.get("intent", "claims_question"))
107
+ state["risk_level"] = decision.get("risk_level", "medium") # type: ignore[assignment]
108
+ self._trace(state, "planner", decision)
109
+ return state
110
+
111
+ def _load_memory(self, state: RagState) -> RagState:
112
+ memory_context = self.memory.search(
113
+ user_id=state.get("user_id", "default_user"),
114
+ query=state["sanitized_query"],
115
+ )
116
+ state["memory_context"] = memory_context
117
+ self._trace(
118
+ state,
119
+ "load_memory",
120
+ {
121
+ "langmem_available": self.memory.langmem_available,
122
+ "has_memory": memory_context != "No prior memory for this user.",
123
+ },
124
+ )
125
+ return state
126
+
127
+ def _semantic_cache(self, state: RagState) -> RagState:
128
+ if not state.get("use_cache", True):
129
+ state["cache_hit"] = False
130
+ self._trace(state, "semantic_cache", {"hit": False, "disabled": True})
131
+ return state
132
+ hit = self.cache.lookup(state["query"])
133
+ if hit:
134
+ state["cache_hit"] = True
135
+ state["answer"] = hit["answer"]
136
+ state["confidence"] = hit["confidence"]
137
+ state["sources"] = hit["sources"]
138
+ has_sources = bool(hit["sources"])
139
+ state["self_rag"] = {
140
+ "passed": True,
141
+ "retrieve": True,
142
+ "isrel": has_sources,
143
+ "issup": has_sources,
144
+ "isuse": bool(hit["answer"]),
145
+ "confidence": hit["confidence"],
146
+ "issues": ["Served from semantic answer cache."],
147
+ }
148
+ self._trace(state, "semantic_cache", {"hit": True, "score": hit["score"]})
149
+ return state
150
+ state["cache_hit"] = False
151
+ self._trace(state, "semantic_cache", {"hit": False})
152
+ return state
153
+
154
+ def _guardrails(self, state: RagState) -> RagState:
155
+ pii = self.guardrails.sanitize(state["query"])
156
+ injection = detect_prompt_injection(state["query"])
157
+ state["sanitized_query"] = pii.text
158
+ if injection:
159
+ state["should_retrieve"] = False
160
+ state["answer"] = "I cannot help with requests that try to override system or safety instructions."
161
+ state["confidence"] = 0.99
162
+ self._trace(
163
+ state,
164
+ "guardrails",
165
+ {
166
+ "pii_findings": pii.findings,
167
+ "prompt_injection_findings": injection,
168
+ "langchain_pii_middlewares": len(self.guardrails.langchain_middlewares),
169
+ },
170
+ )
171
+ return state
172
+
173
+ def _direct_response(self, state: RagState) -> RagState:
174
+ if state.get("answer"):
175
+ return state
176
+ answer = self.llm.invoke_text(
177
+ system=(
178
+ "You are an insurance claims support copilot. Answer simple non-policy questions "
179
+ "briefly. Do not invent coverage, policy terms, claim outcomes, or payments."
180
+ ),
181
+ user=state["sanitized_query"],
182
+ )
183
+ state["answer"] = answer
184
+ state["confidence"] = 0.72
185
+ state["sources"] = []
186
+ state["self_rag"] = {
187
+ "passed": True,
188
+ "confidence": 0.72,
189
+ "issues": ["Direct response path. Retrieval was not required."],
190
+ }
191
+ self._trace(state, "direct_response", {"confidence": state["confidence"]})
192
+ return state
193
+
194
+ def _retrieve(self, state: RagState) -> RagState:
195
+ query = self._prepare_retrieval_query(state)
196
+ metadata_filter = state.get("metadata_filter") # type: ignore[typeddict-item]
197
+ sources = self.retriever.retrieve(query, metadata_filter=metadata_filter)
198
+ cleaned_sources = []
199
+ for source in sources:
200
+ cleaned_sources.append({**source, "text": strip_unsafe_retrieved_text(source.get("text", ""))})
201
+ state["sources"] = cleaned_sources
202
+ self._trace(state, "retrieve", {"count": len(cleaned_sources), "retrieval_query": query})
203
+ return state
204
+
205
+ def _rerank(self, state: RagState) -> RagState:
206
+ reranked = self.reranker.rerank(
207
+ state.get("retrieval_query", state["sanitized_query"]),
208
+ state.get("sources", []),
209
+ top_k=settings.rerank_top_k,
210
+ )
211
+ state["reranked_sources"] = reranked
212
+ self._trace(state, "rerank", {"count": len(reranked)})
213
+ return state
214
+
215
+ def _prepare_retrieval_query(self, state: RagState) -> str:
216
+ if not settings.enable_query_rewrite:
217
+ state["retrieval_query"] = state["sanitized_query"]
218
+ return state["retrieval_query"]
219
+ if state.get("retrieval_query") and state.get("retrieval_query") != state.get("query"):
220
+ return state["retrieval_query"]
221
+ result = self.llm.invoke_json(
222
+ system=(
223
+ "You rewrite user insurance questions into concise retrieval queries for a hybrid "
224
+ "BM25 + vector RAG system. Preserve all facts from the user. Do not answer the "
225
+ "question. Add only helpful insurance terminology that improves retrieval, such as "
226
+ "coverage part, exclusion, deductible, endorsement, claim documents, fault, valuation, "
227
+ "or policy limit when relevant.\n\n"
228
+ "Important retrieval discipline:\n"
229
+ "- Keep the rewritten query focused on the user's requested claim issue.\n"
230
+ "- Do not add benefits, services, or subtopics the user did not ask about.\n"
231
+ "- If the user asks about damage to insured property, focus on the coverage for that "
232
+ "damage, required evidence, deductible, and exclusions.\n"
233
+ "- If a term in the user question is vague, rewrite it into precise insurance language, "
234
+ "but do not change the claim type.\n"
235
+ "- Prefer compact keyword-rich wording over a sentence.\n\n"
236
+ "Return JSON only with keys: query, changed, rationale."
237
+ ),
238
+ user=(
239
+ f"Intent: {state.get('intent', 'unknown')}\n"
240
+ f"Original user question:\n{state['sanitized_query']}"
241
+ ),
242
+ fallback={"query": state["sanitized_query"], "changed": False, "rationale": "fallback"},
243
+ )
244
+ rewritten = str(result.get("query") or state["sanitized_query"]).strip()
245
+ if not rewritten:
246
+ rewritten = state["sanitized_query"]
247
+ state["retrieval_query"] = rewritten
248
+ self._trace(
249
+ state,
250
+ "query_rewrite",
251
+ {
252
+ "original_query": state["sanitized_query"],
253
+ "retrieval_query": rewritten,
254
+ "changed": bool(result.get("changed", rewritten != state["sanitized_query"])),
255
+ "rationale": str(result.get("rationale", ""))[:300],
256
+ },
257
+ )
258
+ return rewritten
259
+
260
+ def _generate(self, state: RagState) -> RagState:
261
+ sources = state.get("reranked_sources", [])
262
+ llm_sources = sources[: settings.max_sources_to_llm]
263
+ evidence = "\n\n".join(
264
+ (
265
+ f"Source {i + 1}: {src.get('source_name', 'unknown')}\n"
266
+ f"Text: {src.get('text', '')[: settings.max_evidence_chars_per_source]}"
267
+ )
268
+ for i, src in enumerate(llm_sources)
269
+ )
270
+ if state.get("intent") == "general_insurance_concept":
271
+ answer = self.llm.invoke_text(
272
+ system=(
273
+ "You are an insurance education assistant. The user is asking a general "
274
+ "insurance concept question, not requesting a claim payment decision. Use only "
275
+ "the provided evidence. Answer briefly and clearly in plain language. Do not use "
276
+ "the claim triage structure. Do not say Likely covered, Likely not covered, or "
277
+ "Needs human review unless the user asks about a claim scenario. Cite sources as "
278
+ "[Source 1], [Source 2], etc. Do not use outside source names."
279
+ ),
280
+ user=(
281
+ f"Question:\n{state['sanitized_query']}\n\n"
282
+ f"Retrieved evidence:\n{evidence}"
283
+ ),
284
+ )
285
+ state["answer"] = self._ensure_source_citation(answer, sources)
286
+ self._trace(state, "generate", {"source_count": len(sources), "mode": "concept_llm"})
287
+ return state
288
+ answer = self._generate_claim_json_answer(state, evidence, sources)
289
+ state["answer"] = self._ensure_source_citation(answer, sources)
290
+ self._trace(state, "generate", {"source_count": len(sources), "mode": "llm"})
291
+ return state
292
+
293
+ def _ensure_source_citation(self, answer: str, sources: list[dict[str, Any]]) -> str:
294
+ if not sources or re.search(r"\[Source\s+\d+\]", answer, flags=re.IGNORECASE):
295
+ return answer
296
+ return answer.rstrip() + " [Source 1]"
297
+
298
+ def _generate_claim_json_answer(self, state: RagState, evidence: str, sources: list[dict[str, Any]]) -> str:
299
+ result = self.llm.invoke_json(
300
+ system=(
301
+ "You are an insurance claim support AI agent. The user describes a claim scenario. "
302
+ "Use only the retrieved evidence. Do not use outside knowledge. Do not invent final "
303
+ "payment approval, denial, claim status, policy terms, or source names.\n\n"
304
+ "Return JSON only with exactly these keys:\n"
305
+ "- decision: one of Likely covered, Likely not covered, Needs human review\n"
306
+ "- reason: one or two evidence-grounded sentences\n"
307
+ "- missing_evidence: short string listing missing documents/facts or None identified\n"
308
+ "- recommended_action: short string with next step, escalation, or review action\n"
309
+ "- sources: short string with citations like [Source 1], [Source 2]\n\n"
310
+ "Rubric:\n"
311
+ "- Likely covered: use only when evidence directly says this cause of loss or scenario is "
312
+ "normally covered by the relevant coverage part and the user's facts do not leave a major "
313
+ "coverage dependency unresolved.\n"
314
+ "- Likely not covered: use only when evidence directly says this cause of loss is excluded, "
315
+ "not covered by the standard policy, or requires separate coverage that the user says they "
316
+ "do not have.\n"
317
+ "- Needs human review: use when payment or coverage depends on unresolved policy-specific "
318
+ "facts, endorsements, sublimits, deductibles, fault, valuation, contestability, fraud "
319
+ "review, regulatory timing, guaranty fund state limits, settlement amount disputes, or "
320
+ "other claim-file details.\n\n"
321
+ "If evidence is incomplete, still choose the best triage label and explain what is missing. "
322
+ "Allowed citations are only [Source 1], [Source 2], etc."
323
+ ),
324
+ user=(
325
+ f"User memory context:\n{state.get('memory_context', 'No prior memory.')}\n\n"
326
+ f"Claim scenario:\n{state['sanitized_query']}\n\n"
327
+ f"Retrieved evidence:\n{evidence}"
328
+ ),
329
+ fallback={
330
+ "decision": "Needs human review",
331
+ "reason": "The available evidence is not sufficient to make a final coverage triage.",
332
+ "missing_evidence": "Policy-specific details and claim-file documentation.",
333
+ "recommended_action": "Escalate for human review with the retrieved evidence.",
334
+ "sources": "[Source 1]" if sources else "",
335
+ },
336
+ )
337
+ decision = str(result.get("decision", "Needs human review"))
338
+ if decision not in {"Likely covered", "Likely not covered", "Needs human review"}:
339
+ decision = "Needs human review"
340
+ sources_text = str(result.get("sources", "")).strip()
341
+ if sources and not re.search(r"\[Source\s+\d+\]", sources_text, flags=re.IGNORECASE):
342
+ sources_text = "[Source 1]"
343
+ return (
344
+ f"Decision: {decision}\n"
345
+ f"Reason: {str(result.get('reason', '')).strip()}\n"
346
+ f"Missing evidence: {str(result.get('missing_evidence', '')).strip()}\n"
347
+ f"Recommended action: {str(result.get('recommended_action', '')).strip()}\n"
348
+ f"Sources: {sources_text}"
349
+ )
350
+
351
+ def _critique(self, state: RagState) -> RagState:
352
+ if state.get("intent") == "general_insurance_concept":
353
+ sources = state.get("reranked_sources", [])
354
+ answer = state.get("answer", "")
355
+ critique = {
356
+ "passed": bool(sources) and bool(answer),
357
+ "retrieve": True,
358
+ "isrel": bool(sources),
359
+ "issup": bool(sources) and "[source" in answer.lower(),
360
+ "isuse": len(answer.strip()) > 20,
361
+ "confidence": 0.84 if sources and answer else 0.45,
362
+ "relevance_score": 0.85 if sources else 0.0,
363
+ "faithfulness_score": 0.8 if sources and "[source" in answer.lower() else 0.35,
364
+ "evidence_score": min(0.9, 0.35 + len(sources) * 0.1),
365
+ "needs_rewrite": False,
366
+ "rewrite_query": None,
367
+ "issues": ["General insurance concept answer with retrieved sources."],
368
+ }
369
+ state["self_rag"] = critique
370
+ state["confidence"] = float(critique["confidence"])
371
+ self._trace(state, "critique", critique)
372
+ return state
373
+ critique = self.self_rag.critique(
374
+ query=state["sanitized_query"],
375
+ answer=state.get("answer", ""),
376
+ sources=state.get("reranked_sources", []),
377
+ iteration=int(state.get("iteration", 0)),
378
+ )
379
+ state["self_rag"] = critique
380
+ state["confidence"] = float(critique.get("confidence", 0.0))
381
+ self._trace(state, "critique", critique)
382
+ return state
383
+
384
+ def _rewrite(self, state: RagState) -> RagState:
385
+ critique = state.get("self_rag", {})
386
+ fallback_query = critique.get("rewrite_query") or state.get("retrieval_query") or state["sanitized_query"]
387
+ result = self.llm.invoke_json(
388
+ system=(
389
+ "You are rewriting a failed insurance RAG retrieval query for a retry. Use the "
390
+ "critique issues and original user question to create a better retrieval query. "
391
+ "Preserve the user's facts. Do not answer the question. Return JSON only with "
392
+ "keys: query, rationale."
393
+ ),
394
+ user=(
395
+ f"Original user question:\n{state['sanitized_query']}\n\n"
396
+ f"Previous retrieval query:\n{state.get('retrieval_query', state['sanitized_query'])}\n\n"
397
+ f"Critique issues:\n{json.dumps(critique.get('issues', []), ensure_ascii=True)}"
398
+ ),
399
+ fallback={"query": fallback_query, "rationale": "fallback"},
400
+ )
401
+ rewrite = str(result.get("query") or fallback_query).strip() or str(fallback_query)
402
+ state["retrieval_query"] = rewrite
403
+ state["iteration"] = int(state.get("iteration", 0)) + 1
404
+ self._trace(
405
+ state,
406
+ "rewrite",
407
+ {
408
+ "retrieval_query": state["retrieval_query"],
409
+ "iteration": state["iteration"],
410
+ "rationale": str(result.get("rationale", ""))[:300],
411
+ },
412
+ )
413
+ return state
414
+
415
+ def _finalize(self, state: RagState) -> RagState:
416
+ if state.get("answer"):
417
+ sanitized = self.guardrails.sanitize(state["answer"]).text
418
+ state["answer"] = self.guardrails.clean_legacy_false_positive_placeholders(sanitized)
419
+ if (
420
+ state.get("use_cache", True)
421
+ and not state.get("cache_hit")
422
+ and state.get("answer")
423
+ and not self._has_unsupported_citation(state)
424
+ ):
425
+ self.cache.save(
426
+ query=state["query"],
427
+ answer=state["answer"],
428
+ confidence=float(state.get("confidence", 0.0)),
429
+ sources=state.get("reranked_sources") or state.get("sources", []),
430
+ )
431
+ if state.get("answer") and state.get("reranked_sources"):
432
+ self.memory.save_interaction(
433
+ user_id=state.get("user_id", "default_user"),
434
+ query=state["query"],
435
+ answer=state["answer"],
436
+ critique=state.get("self_rag", {}),
437
+ sources=state.get("reranked_sources", []),
438
+ )
439
+ self._trace(
440
+ state,
441
+ "finalize",
442
+ {
443
+ "cache_hit": state.get("cache_hit", False),
444
+ "confidence": state.get("confidence", 0.0),
445
+ "iterations": state.get("iteration", 0),
446
+ },
447
+ )
448
+ return state
449
+
450
+ def _has_unsupported_citation(self, state: RagState) -> bool:
451
+ answer = state.get("answer", "").lower()
452
+ blocked_markers = [
453
+ "insurance information institute",
454
+ "source: none",
455
+ "source: insurance",
456
+ "according to standard insurance terminology",
457
+ ]
458
+ return any(marker in answer for marker in blocked_markers)
459
+
460
+ def _route_cache(self, state: RagState) -> str:
461
+ return "hit" if state.get("cache_hit") else "miss"
462
+
463
+ def _route_retrieval(self, state: RagState) -> str:
464
+ if state.get("answer") and "override system" in state["answer"]:
465
+ return "direct"
466
+ return "retrieve" if state.get("should_retrieve", True) else "direct"
467
+
468
+ def _route_critique(self, state: RagState) -> str:
469
+ critique = state.get("self_rag", {})
470
+ passed = bool(critique.get("passed", False))
471
+ confidence = float(critique.get("confidence", 0.0))
472
+ iteration = int(state.get("iteration", 0))
473
+ if passed and confidence >= 0.68:
474
+ return "accept"
475
+ if iteration >= settings.self_rag_max_loops:
476
+ return "accept"
477
+ if critique.get("needs_rewrite", True):
478
+ return "retry"
479
+ return "accept"
480
+
481
+ def _trace(self, state: RagState, node: str, payload: dict[str, Any]) -> None:
482
+ trace = state.setdefault("trace", [])
483
+ trace.append({"node": node, **payload})
484
+
485
+ def _save_trace(self, state: RagState) -> None:
486
+ with db() as conn:
487
+ conn.execute(
488
+ """
489
+ INSERT OR REPLACE INTO traces(request_id, query, trace_json)
490
+ VALUES (?, ?, ?)
491
+ """,
492
+ (
493
+ state["request_id"],
494
+ state["query"],
495
+ json.dumps(state.get("trace", []), ensure_ascii=True),
496
+ ),
497
+ )
app/rag/hybrid_retriever.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from app.core.config import settings
4
+ from app.rag.bm25 import BM25Index
5
+ from app.rag.embeddings import get_embedding_model
6
+ from app.rag.qdrant_store import QdrantVectorStore
7
+ from app.rag.rrf import reciprocal_rank_fusion
8
+
9
+
10
+ class HybridRetriever:
11
+ def __init__(self) -> None:
12
+ self.embeddings = get_embedding_model()
13
+ self.qdrant = QdrantVectorStore()
14
+ self.bm25 = BM25Index.load_or_create()
15
+
16
+ def retrieve(self, query: str, metadata_filter: dict[str, Any] | None = None) -> list[dict[str, Any]]:
17
+ vector = self.embeddings.embed_query(query)
18
+ vector_hits = self.qdrant.search_chunks(
19
+ vector=vector,
20
+ top_k=settings.retrieval_top_k,
21
+ metadata_filter=metadata_filter,
22
+ )
23
+ for hit in vector_hits:
24
+ hit["metadata"] = {**hit.get("metadata", {}), "retriever": "qdrant"}
25
+ bm25_hits = self.bm25.search(query, top_k=settings.retrieval_top_k)
26
+ return reciprocal_rank_fusion(
27
+ [bm25_hits, vector_hits],
28
+ top_k=max(settings.retrieval_top_k, settings.rerank_top_k),
29
+ )
app/rag/ingestion.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from langchain_community.document_loaders import PyPDFLoader
6
+ from langchain_core.documents import Document
7
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
8
+
9
+ from app.core.config import settings
10
+ from app.rag.bm25 import BM25Index
11
+ from app.rag.document_cache import DocumentCache
12
+ from app.rag.embeddings import get_embedding_model
13
+ from app.rag.qdrant_store import QdrantVectorStore
14
+ from app.rag.text import new_id, normalize_text, sha256_text
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class DocumentIngestionService:
20
+ def __init__(self) -> None:
21
+ self.cache = DocumentCache()
22
+ self._embeddings = None
23
+ self.qdrant = QdrantVectorStore()
24
+ self.splitter = RecursiveCharacterTextSplitter(
25
+ chunk_size=900,
26
+ chunk_overlap=140,
27
+ separators=["\n\n", "\n", ". ", " ", ""],
28
+ )
29
+
30
+ @property
31
+ def embeddings(self):
32
+ if self._embeddings is None:
33
+ self._embeddings = get_embedding_model()
34
+ return self._embeddings
35
+
36
+ def ingest_text(self, text: str, source_name: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
37
+ docs = [Document(page_content=text, metadata=metadata or {})]
38
+ return self.ingest_documents(docs, source_name=source_name)
39
+
40
+ def ingest_pdf(self, pdf_path: Path) -> dict[str, Any]:
41
+ loader = PyPDFLoader(str(pdf_path))
42
+ docs = loader.load()
43
+ metadata = {
44
+ "document_type": "pdf",
45
+ "source_path": str(pdf_path),
46
+ "source_name": pdf_path.name,
47
+ }
48
+ enriched = [
49
+ Document(page_content=doc.page_content, metadata={**metadata, **doc.metadata})
50
+ for doc in docs
51
+ ]
52
+ return self.ingest_documents(enriched, source_name=pdf_path.name)
53
+
54
+ def ingest_pdf_directory(self, directory: str | Path | None = None) -> dict[str, Any]:
55
+ root = Path(directory or settings.document_dir)
56
+ root.mkdir(parents=True, exist_ok=True)
57
+ pdfs = sorted(
58
+ path for path in root.rglob("*.pdf")
59
+ if not any(part.startswith(".") for part in path.parts)
60
+ )
61
+ results = []
62
+ for pdf in pdfs:
63
+ try:
64
+ results.append({"file": str(pdf), **self.ingest_pdf(pdf)})
65
+ except Exception as exc:
66
+ logger.exception("Failed to ingest PDF %s", pdf)
67
+ results.append({"file": str(pdf), "status": "failed", "reason": str(exc)})
68
+ return {
69
+ "status": "scanned",
70
+ "directory": str(root),
71
+ "pdf_count": len(pdfs),
72
+ "results": results,
73
+ }
74
+
75
+ def ingest_documents(self, docs: list[Document], source_name: str) -> dict[str, Any]:
76
+ text = "\n\n".join(doc.page_content for doc in docs if doc.page_content.strip())
77
+ if not text.strip():
78
+ return {
79
+ "status": "skipped_empty_document",
80
+ "source_name": source_name,
81
+ "embedded_chunks": 0,
82
+ "skipped_chunks": 0,
83
+ }
84
+ decision = self.cache.inspect(text)
85
+ if not decision.should_embed:
86
+ return {
87
+ "status": decision.status,
88
+ "matched_doc_id": decision.matched_doc_id,
89
+ "reason": decision.reason,
90
+ "embedded_chunks": 0,
91
+ "skipped_chunks": 0,
92
+ }
93
+
94
+ doc_id = new_id("doc")
95
+ self.cache.save_document(doc_id, source_name, text, "embedded")
96
+
97
+ split_docs = self.splitter.split_documents(docs)
98
+ chunk_records = []
99
+ new_chunk_texts = []
100
+ skipped_chunks = 0
101
+
102
+ for index, split_doc in enumerate(split_docs):
103
+ chunk = split_doc.page_content.strip()
104
+ if not chunk:
105
+ continue
106
+ text_hash = sha256_text(normalize_text(chunk))
107
+ chunk_id = new_id("chunk")
108
+ chunk_metadata = {
109
+ **split_doc.metadata,
110
+ "doc_id": doc_id,
111
+ "chunk_id": chunk_id,
112
+ "chunk_index": index,
113
+ "source_name": source_name,
114
+ "text_hash": text_hash,
115
+ }
116
+ if self.cache.chunk_exists(text_hash):
117
+ skipped_chunks += 1
118
+ self.cache.save_chunk(chunk_id, doc_id, index, chunk, text_hash, chunk_metadata, embedded=False)
119
+ continue
120
+ chunk_records.append((chunk_id, index, chunk, text_hash, chunk_metadata))
121
+ new_chunk_texts.append(chunk)
122
+
123
+ vectors = self.embeddings.embed_documents(new_chunk_texts)
124
+ points = []
125
+ for (chunk_id, index, chunk, text_hash, chunk_metadata), vector in zip(chunk_records, vectors):
126
+ self.cache.save_chunk(chunk_id, doc_id, index, chunk, text_hash, chunk_metadata, embedded=True)
127
+ points.append(
128
+ {
129
+ "id": chunk_id,
130
+ "vector": vector,
131
+ "payload": {**chunk_metadata, "text": chunk},
132
+ }
133
+ )
134
+
135
+ self.qdrant.upsert_chunks(points)
136
+ BM25Index.from_db().save()
137
+
138
+ return {
139
+ "status": "embedded",
140
+ "doc_id": doc_id,
141
+ "embedded_chunks": len(points),
142
+ "skipped_chunks": skipped_chunks,
143
+ }
app/rag/qdrant_store.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from qdrant_client import QdrantClient
7
+ from qdrant_client.http.models import Distance, FieldCondition, Filter, MatchValue, PointStruct, VectorParams
8
+
9
+ from app.core.config import settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @lru_cache
15
+ def get_qdrant_client() -> QdrantClient:
16
+ if settings.qdrant_url.startswith("local:"):
17
+ path = settings.qdrant_url.removeprefix("local:")
18
+ if path == ":memory:":
19
+ return QdrantClient(":memory:")
20
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
21
+ return QdrantClient(path=path)
22
+ return QdrantClient(
23
+ url=settings.qdrant_url,
24
+ api_key=settings.qdrant_api_key or None,
25
+ timeout=10,
26
+ )
27
+
28
+
29
+ class QdrantVectorStore:
30
+ def __init__(self) -> None:
31
+ self.client = get_qdrant_client()
32
+ def ensure_collections(self) -> None:
33
+ for name in [settings.qdrant_collection, settings.qdrant_cache_collection]:
34
+ existing = [c.name for c in self.client.get_collections().collections]
35
+ if name not in existing:
36
+ self.client.create_collection(
37
+ collection_name=name,
38
+ vectors_config=VectorParams(size=settings.embedding_dim, distance=Distance.COSINE),
39
+ )
40
+ logger.info("Created Qdrant collection %s", name)
41
+
42
+ def upsert_chunks(self, points: list[dict[str, Any]]) -> None:
43
+ if not points:
44
+ return
45
+ self.client.upsert(
46
+ collection_name=settings.qdrant_collection,
47
+ points=[
48
+ PointStruct(id=p["id"], vector=p["vector"], payload=p["payload"])
49
+ for p in points
50
+ ],
51
+ )
52
+
53
+ def search_chunks(
54
+ self,
55
+ vector: list[float],
56
+ top_k: int,
57
+ metadata_filter: dict[str, Any] | None = None,
58
+ ) -> list[dict[str, Any]]:
59
+ query_filter = self._build_filter(metadata_filter)
60
+ hits = self.client.query_points(
61
+ collection_name=settings.qdrant_collection,
62
+ query=vector,
63
+ query_filter=query_filter,
64
+ limit=top_k,
65
+ with_payload=True,
66
+ ).points
67
+ return [
68
+ {
69
+ "id": str(hit.id),
70
+ "score": float(hit.score),
71
+ "text": hit.payload.get("text", ""),
72
+ "source_name": hit.payload.get("source_name", "unknown"),
73
+ "metadata": hit.payload,
74
+ }
75
+ for hit in hits
76
+ ]
77
+
78
+ def upsert_cache_answer(self, cache_id: str, vector: list[float], payload: dict[str, Any]) -> None:
79
+ self.client.upsert(
80
+ collection_name=settings.qdrant_cache_collection,
81
+ points=[PointStruct(id=cache_id, vector=vector, payload=payload)],
82
+ )
83
+
84
+ def search_cache(self, vector: list[float], top_k: int = 1) -> list[dict[str, Any]]:
85
+ hits = self.client.query_points(
86
+ collection_name=settings.qdrant_cache_collection,
87
+ query=vector,
88
+ limit=top_k,
89
+ with_payload=True,
90
+ ).points
91
+ return [
92
+ {
93
+ "id": str(hit.id),
94
+ "score": float(hit.score),
95
+ "payload": hit.payload or {},
96
+ }
97
+ for hit in hits
98
+ ]
99
+
100
+ def _build_filter(self, metadata_filter: dict[str, Any] | None) -> Filter | None:
101
+ if not metadata_filter:
102
+ return None
103
+ conditions = [
104
+ FieldCondition(key=key, match=MatchValue(value=value))
105
+ for key, value in metadata_filter.items()
106
+ if value is not None and value != ""
107
+ ]
108
+ if not conditions:
109
+ return None
110
+ return Filter(must=conditions)
app/rag/reranker.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+ from functools import lru_cache
4
+ from typing import Any
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @lru_cache
10
+ def get_flashrank_ranker():
11
+ from flashrank import Ranker
12
+
13
+ return Ranker(max_length=256)
14
+
15
+
16
+ class FlashRankReranker:
17
+ def __init__(self) -> None:
18
+ self._ranker = None
19
+
20
+ @property
21
+ def ranker(self):
22
+ if self._ranker is None:
23
+ self._ranker = get_flashrank_ranker()
24
+ return self._ranker
25
+
26
+ def rerank(self, query: str, docs: list[dict[str, Any]], top_k: int) -> list[dict[str, Any]]:
27
+ if not docs:
28
+ return []
29
+ try:
30
+ from flashrank import RerankRequest
31
+
32
+ passages = [
33
+ {
34
+ "id": doc["id"],
35
+ "text": doc["text"],
36
+ "meta": {**doc.get("metadata", {}), "source_name": doc.get("source_name")},
37
+ }
38
+ for doc in docs
39
+ ]
40
+ result = self.ranker.rerank(RerankRequest(query=query, passages=passages))
41
+ by_id = {doc["id"]: doc for doc in docs}
42
+ reranked = []
43
+ seen = set()
44
+ for item in result:
45
+ doc = by_id[str(item["id"])]
46
+ fingerprint = self._fingerprint(doc.get("text", ""))
47
+ if fingerprint in seen:
48
+ continue
49
+ seen.add(fingerprint)
50
+ reranked.append(
51
+ {
52
+ **doc,
53
+ "score": float(item.get("score", doc.get("score", 0.0))),
54
+ "metadata": {**doc.get("metadata", {}), "reranker": "flashrank"},
55
+ }
56
+ )
57
+ if len(reranked) >= top_k:
58
+ break
59
+ return reranked
60
+ except Exception as exc: # pragma: no cover - fallback for missing model cache
61
+ logger.warning("FlashRank fallback used: %s", exc)
62
+ return self._dedupe(
63
+ sorted(docs, key=lambda d: d.get("score", d.get("rrf_score", 0.0)), reverse=True),
64
+ top_k=top_k,
65
+ )
66
+
67
+ def _dedupe(self, docs: list[dict[str, Any]], top_k: int) -> list[dict[str, Any]]:
68
+ selected = []
69
+ seen = set()
70
+ for doc in docs:
71
+ fingerprint = self._fingerprint(doc.get("text", ""))
72
+ if fingerprint in seen:
73
+ continue
74
+ seen.add(fingerprint)
75
+ selected.append(doc)
76
+ if len(selected) >= top_k:
77
+ break
78
+ return selected
79
+
80
+ def _fingerprint(self, text: str) -> str:
81
+ normalized = re.sub(r"\s+", " ", text.lower()).strip()
82
+ return normalized[:500]
app/rag/rrf.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from typing import Any
3
+
4
+
5
+ def reciprocal_rank_fusion(
6
+ result_sets: list[list[dict[str, Any]]],
7
+ k: int = 60,
8
+ top_k: int = 10,
9
+ ) -> list[dict[str, Any]]:
10
+ scores: dict[str, float] = defaultdict(float)
11
+ docs: dict[str, dict[str, Any]] = {}
12
+
13
+ for results in result_sets:
14
+ for rank, doc in enumerate(results, start=1):
15
+ doc_id = doc["id"]
16
+ scores[doc_id] += 1.0 / (k + rank)
17
+ docs[doc_id] = {**doc, "rrf_score": scores[doc_id]}
18
+
19
+ ranked = sorted(docs.values(), key=lambda item: scores[item["id"]], reverse=True)
20
+ return ranked[:top_k]
app/rag/self_rag.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+ from app.core.config import settings
6
+ from app.services.groq_llm import GroqLLM
7
+
8
+
9
+ class SelfRAG:
10
+ def __init__(self) -> None:
11
+ self.llm = GroqLLM()
12
+
13
+ def grade_retrieval_need(self, query: str) -> dict[str, Any]:
14
+ normalized = re.sub(r"\s+", " ", query.lower()).strip(" ?!.")
15
+ normalized = re.sub(r"^\d+[\).\-\s]+", "", normalized).strip(" ?!.")
16
+ simple_markers = {"hello", "hi", "hey", "help", "what can you do"}
17
+ fallback = {
18
+ "should_retrieve": normalized not in simple_markers,
19
+ "retrieve": normalized not in simple_markers,
20
+ "intent": "smalltalk" if normalized in simple_markers else "claim_scenario",
21
+ "risk_level": "low" if normalized in simple_markers else "medium",
22
+ }
23
+ result = self.llm.invoke_json(
24
+ system=(
25
+ "You are the planner for an insurance RAG assistant. Classify the user's query and "
26
+ "decide whether retrieval from the insurance knowledge base is needed.\n\n"
27
+ "Return JSON with exactly these keys:\n"
28
+ "- should_retrieve: boolean\n"
29
+ "- retrieve: boolean, same value as should_retrieve\n"
30
+ "- intent: one of smalltalk, general_insurance_concept, claim_scenario\n"
31
+ "- risk_level: one of low, medium, high\n\n"
32
+ "Use general_insurance_concept for educational questions about insurance terms, "
33
+ "regulation, compliance, procedures, definitions, or how insurance works. These "
34
+ "questions should retrieve if they are insurance-related.\n"
35
+ "Use claim_scenario when the user describes an event, loss, damage, theft, injury, "
36
+ "death, bill, repair, approval, denial, coverage, or asks whether insurance will pay. "
37
+ "These questions should retrieve.\n"
38
+ "Use smalltalk only for greetings or capability questions. These usually do not retrieve.\n"
39
+ "High risk means coverage decisions, denial, settlement, legal, fraud, death, injury, "
40
+ "large loss, regulatory complaint, or money."
41
+ ),
42
+ user=f"Query: {query}",
43
+ fallback=fallback,
44
+ )
45
+ intent = str(result.get("intent", fallback["intent"]))
46
+ if intent not in {"smalltalk", "general_insurance_concept", "claim_scenario"}:
47
+ intent = fallback["intent"]
48
+ should_retrieve = bool(result.get("should_retrieve", fallback["should_retrieve"]))
49
+ if intent in {"general_insurance_concept", "claim_scenario"}:
50
+ should_retrieve = True
51
+ if intent == "smalltalk":
52
+ should_retrieve = False
53
+ risk_level = str(result.get("risk_level", fallback["risk_level"]))
54
+ if risk_level not in {"low", "medium", "high"}:
55
+ risk_level = fallback["risk_level"]
56
+ return {
57
+ "should_retrieve": should_retrieve,
58
+ "retrieve": should_retrieve,
59
+ "intent": intent,
60
+ "risk_level": risk_level,
61
+ }
62
+
63
+ def critique(
64
+ self,
65
+ query: str,
66
+ answer: str,
67
+ sources: list[dict[str, Any]],
68
+ iteration: int,
69
+ ) -> dict[str, Any]:
70
+ if not sources:
71
+ return {
72
+ "passed": False,
73
+ "retrieve": True,
74
+ "isrel": False,
75
+ "issup": False,
76
+ "isuse": False,
77
+ "confidence": 0.35,
78
+ "relevance_score": 0.0,
79
+ "faithfulness_score": 0.0,
80
+ "evidence_score": 0.0,
81
+ "needs_rewrite": iteration == 0,
82
+ "rewrite_query": query,
83
+ "issues": ["No retrieved evidence was available."],
84
+ }
85
+
86
+ if settings.low_latency_mode:
87
+ return self._heuristic_critique(answer, sources, iteration)
88
+
89
+ evidence = "\n\n".join(
90
+ f"[{i + 1}] {src.get('source_name', 'source')} :: {src.get('text', '')[:900]}"
91
+ for i, src in enumerate(sources)
92
+ )
93
+ fallback = self._heuristic_critique(answer, sources, iteration)
94
+ result = self.llm.invoke_json(
95
+ system=(
96
+ "You are a Self-RAG evaluator for an insurance claims assistant. Return JSON with "
97
+ "the classic Self-RAG labels: retrieve, isrel, issup, isuse. Definitions: retrieve "
98
+ "means external evidence was needed; ISREL means retrieved passages are relevant; "
99
+ "ISSUP means the generated answer is supported by those passages; ISUSE means the "
100
+ "overall response is useful for the user's claim scenario. Also return passed, "
101
+ "confidence, relevance_score, faithfulness_score, evidence_score, needs_rewrite, "
102
+ "rewrite_query, and issues."
103
+ ),
104
+ user=f"Query:\n{query}\n\nDraft answer:\n{answer}\n\nEvidence:\n{evidence}",
105
+ fallback=fallback,
106
+ )
107
+ return {
108
+ "passed": bool(result.get("passed", False)),
109
+ "retrieve": bool(result.get("retrieve", True)),
110
+ "isrel": bool(result.get("isrel", False)),
111
+ "issup": bool(result.get("issup", False)),
112
+ "isuse": bool(result.get("isuse", False)),
113
+ "confidence": float(result.get("confidence", 0.0)),
114
+ "relevance_score": float(result.get("relevance_score", 0.0)),
115
+ "faithfulness_score": float(result.get("faithfulness_score", 0.0)),
116
+ "evidence_score": float(result.get("evidence_score", 0.0)),
117
+ "needs_rewrite": bool(result.get("needs_rewrite", False)),
118
+ "rewrite_query": result.get("rewrite_query") or query,
119
+ "issues": result.get("issues", []),
120
+ }
121
+
122
+ def _heuristic_critique(self, answer: str, sources: list[dict[str, Any]], iteration: int) -> dict[str, Any]:
123
+ source_count = len(sources)
124
+ has_answer = len(answer.strip()) > 40
125
+ answer_lower = answer.lower()
126
+ has_decision = "decision:" in answer_lower
127
+ has_missing = "missing evidence:" in answer_lower
128
+ has_action = "recommended action" in answer_lower or "recommended tool" in answer_lower
129
+ has_citation = "[source" in answer_lower
130
+ query_terms = set()
131
+ source_terms = set()
132
+ for source in sources:
133
+ source_terms.update(re.findall(r"[a-zA-Z0-9_]+", source.get("text", "").lower()))
134
+ isrel = source_count > 0 and bool(source_terms)
135
+ issup = has_citation and source_count > 0
136
+ isuse = has_answer and has_decision and has_missing and has_action
137
+ confidence = min(
138
+ 0.95,
139
+ 0.35
140
+ + source_count * 0.07
141
+ + (0.15 if has_answer else 0)
142
+ + (0.15 if isrel else 0)
143
+ + (0.15 if issup else 0)
144
+ + (0.15 if isuse else 0),
145
+ )
146
+ passed = isrel and issup and isuse and (confidence >= 0.68 or iteration > 0)
147
+ issues = []
148
+ if not isrel:
149
+ issues.append("ISREL failed: retrieved passages appear weak or missing.")
150
+ if not issup:
151
+ issues.append("ISSUP failed: answer lacks clear support citation.")
152
+ if not isuse:
153
+ issues.append("ISUSE failed: answer is missing decision, missing evidence, or action structure.")
154
+ return {
155
+ "passed": passed,
156
+ "retrieve": True,
157
+ "isrel": isrel,
158
+ "issup": issup,
159
+ "isuse": isuse,
160
+ "confidence": confidence,
161
+ "relevance_score": 0.9 if isrel else 0.25,
162
+ "faithfulness_score": 0.85 if issup else 0.35,
163
+ "evidence_score": min(0.9, 0.35 + source_count * 0.1),
164
+ "needs_rewrite": not passed and iteration == 0,
165
+ "rewrite_query": None,
166
+ "issues": issues,
167
+ }
168
+
169
+
170
+ def safe_json(data: Any) -> str:
171
+ return json.dumps(data, ensure_ascii=True)
app/rag/semantic_cache.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any
3
+
4
+ from app.core.config import settings
5
+ from app.rag.embeddings import get_embedding_model
6
+ from app.rag.qdrant_store import QdrantVectorStore
7
+ from app.rag.text import new_id, normalize_text
8
+
9
+
10
+ ANSWER_CACHE_VERSION = "rag-grounded-v4"
11
+
12
+
13
+ class SemanticAnswerCache:
14
+ def __init__(self) -> None:
15
+ self.embeddings = get_embedding_model()
16
+ self.store = QdrantVectorStore()
17
+
18
+ def lookup(self, query: str) -> dict[str, Any] | None:
19
+ vector = self.embeddings.embed_query(normalize_text(query))
20
+ hits = self.store.search_cache(vector, top_k=1)
21
+ if not hits:
22
+ return None
23
+ best = hits[0]
24
+ if best["score"] < settings.semantic_cache_threshold:
25
+ return None
26
+ payload = best["payload"]
27
+ if payload.get("cache_version") != ANSWER_CACHE_VERSION:
28
+ return None
29
+ return {
30
+ "answer": payload.get("answer", ""),
31
+ "confidence": float(payload.get("confidence", 0.0)),
32
+ "sources": json.loads(payload.get("sources_json", "[]")),
33
+ "score": best["score"],
34
+ }
35
+
36
+ def save(self, query: str, answer: str, confidence: float, sources: list[dict[str, Any]]) -> None:
37
+ if confidence < 0.75:
38
+ return
39
+ normalized = normalize_text(query)
40
+ vector = self.embeddings.embed_query(normalized)
41
+ cache_id = new_id("cache")
42
+ self.store.upsert_cache_answer(
43
+ cache_id=cache_id,
44
+ vector=vector,
45
+ payload={
46
+ "query": query,
47
+ "normalized_query": normalized,
48
+ "answer": answer,
49
+ "confidence": confidence,
50
+ "cache_version": ANSWER_CACHE_VERSION,
51
+ "sources_json": json.dumps(sources, ensure_ascii=True),
52
+ },
53
+ )
app/rag/state.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Literal, TypedDict
2
+
3
+
4
+ class Source(TypedDict, total=False):
5
+ id: str
6
+ text: str
7
+ source_name: str
8
+ score: float
9
+ metadata: dict[str, Any]
10
+
11
+
12
+ class Critique(TypedDict, total=False):
13
+ passed: bool
14
+ retrieve: bool
15
+ isrel: bool
16
+ issup: bool
17
+ isuse: bool
18
+ confidence: float
19
+ relevance_score: float
20
+ faithfulness_score: float
21
+ evidence_score: float
22
+ needs_rewrite: bool
23
+ rewrite_query: str | None
24
+ issues: list[str]
25
+
26
+
27
+ class RagState(TypedDict, total=False):
28
+ request_id: str
29
+ query: str
30
+ sanitized_query: str
31
+ retrieval_query: str
32
+ normalized_query: str
33
+ intent: str
34
+ should_retrieve: bool
35
+ risk_level: Literal["low", "medium", "high"]
36
+ cache_hit: bool
37
+ use_cache: bool
38
+ answer: str
39
+ confidence: float
40
+ user_id: str
41
+ memory_context: str
42
+ metadata_filter: dict[str, Any]
43
+ sources: list[Source]
44
+ reranked_sources: list[Source]
45
+ self_rag: Critique
46
+ iteration: int
47
+ trace: list[dict[str, Any]]
app/rag/text.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import re
3
+ import uuid
4
+
5
+
6
+ def normalize_text(text: str) -> str:
7
+ text = text.replace("\x00", " ")
8
+ text = re.sub(r"\s+", " ", text).strip().lower()
9
+ return text
10
+
11
+
12
+ def sha256_text(text: str) -> str:
13
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
14
+
15
+
16
+ def new_id(prefix: str) -> str:
17
+ return str(uuid.uuid4())
18
+
19
+
20
+ def tokenize(text: str) -> list[str]:
21
+ return re.findall(r"[a-zA-Z0-9_]+", text.lower())
22
+
23
+
24
+ def chunk_text(text: str, chunk_size: int = 900, overlap: int = 140) -> list[str]:
25
+ normalized = re.sub(r"\s+", " ", text).strip()
26
+ if not normalized:
27
+ return []
28
+ chunks: list[str] = []
29
+ start = 0
30
+ while start < len(normalized):
31
+ end = min(start + chunk_size, len(normalized))
32
+ chunk = normalized[start:end].strip()
33
+ if chunk:
34
+ chunks.append(chunk)
35
+ if end == len(normalized):
36
+ break
37
+ start = max(0, end - overlap)
38
+ return chunks
39
+
40
+
41
+ def simhash(text: str, bits: int = 64) -> int:
42
+ tokens = tokenize(normalize_text(text))
43
+ vector = [0] * bits
44
+ for token in tokens:
45
+ digest = int(hashlib.md5(token.encode("utf-8")).hexdigest(), 16)
46
+ for i in range(bits):
47
+ vector[i] += 1 if digest & (1 << i) else -1
48
+ value = 0
49
+ for i, weight in enumerate(vector):
50
+ if weight > 0:
51
+ value |= 1 << i
52
+ return value
53
+
54
+
55
+ def hamming_distance(a: int, b: int) -> int:
56
+ return (a ^ b).bit_count()
app/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """External service clients."""
app/services/groq_llm.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import re
4
+ from typing import Any
5
+
6
+ from app.core.config import settings
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class GroqLLM:
12
+ def __init__(self) -> None:
13
+ self._llm = None
14
+
15
+ @property
16
+ def llm(self):
17
+ if self._llm is None:
18
+ if not settings.groq_api_key:
19
+ return None
20
+ from langchain_groq import ChatGroq
21
+
22
+ self._llm = ChatGroq(
23
+ model=settings.groq_model,
24
+ temperature=0,
25
+ max_retries=2,
26
+ api_key=settings.groq_api_key,
27
+ )
28
+ return self._llm
29
+
30
+ def invoke_text(self, system: str, user: str) -> str:
31
+ if self.llm is None:
32
+ return self._fallback_text(user)
33
+ response = self.llm.invoke(
34
+ [
35
+ ("system", system),
36
+ ("user", user),
37
+ ]
38
+ )
39
+ return str(response.content)
40
+
41
+ def invoke_json(self, system: str, user: str, fallback: dict[str, Any]) -> dict[str, Any]:
42
+ if self.llm is None:
43
+ return fallback
44
+ try:
45
+ response = self.llm.invoke(
46
+ [
47
+ ("system", system + "\nReturn only valid JSON."),
48
+ ("user", user),
49
+ ],
50
+ {"response_format": {"type": "json_object"}},
51
+ )
52
+ return self._parse_json(str(response.content), fallback)
53
+ except Exception as exc:
54
+ logger.warning("Groq JSON call failed, using fallback: %s", exc)
55
+ return fallback
56
+
57
+ def _parse_json(self, text: str, fallback: dict[str, Any]) -> dict[str, Any]:
58
+ try:
59
+ return json.loads(text)
60
+ except json.JSONDecodeError:
61
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
62
+ if not match:
63
+ return fallback
64
+ try:
65
+ return json.loads(match.group(0))
66
+ except json.JSONDecodeError:
67
+ return fallback
68
+
69
+ def _fallback_text(self, user: str) -> str:
70
+ return (
71
+ "The Groq API key is not configured, so this local fallback cannot produce a full "
72
+ "LLM answer. Add GROQ_API_KEY to .env and rerun the service."
73
+ )
app/services/memory.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+ from app.db.sqlite import db
6
+ from app.rag.text import new_id, normalize_text, tokenize
7
+
8
+
9
+ class ClaimMemoryService:
10
+ """LangMem-ready memory facade with durable SQLite fallback.
11
+
12
+ The project initializes LangMem memory tools when the package is present, while
13
+ storing/retrieving operational memories locally so the app works without an
14
+ external LangGraph store.
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ self.langmem_available = False
19
+ self.store = None
20
+ self.manage_memory_tool = None
21
+ self.search_memory_tool = None
22
+ self._init_langmem()
23
+
24
+ def _init_langmem(self) -> None:
25
+ try:
26
+ from langmem import create_manage_memory_tool, create_search_memory_tool # noqa: F401
27
+ from langgraph.store.memory import InMemoryStore
28
+
29
+ self.store = InMemoryStore()
30
+ namespace = ("claim_support_memories", "{user_id}")
31
+ self.manage_memory_tool = create_manage_memory_tool(namespace, store=self.store)
32
+ self.search_memory_tool = create_search_memory_tool(namespace, store=self.store)
33
+ self.langmem_available = True
34
+ except Exception:
35
+ self.langmem_available = False
36
+
37
+ def search(self, user_id: str, query: str, limit: int = 4) -> str:
38
+ langmem_lines = self._search_langmem_store(user_id, query, limit)
39
+ query_terms = set(tokenize(query))
40
+ with db() as conn:
41
+ rows = conn.execute(
42
+ """
43
+ SELECT kind, content, metadata_json, created_at
44
+ FROM memories
45
+ WHERE user_id = ?
46
+ ORDER BY created_at DESC
47
+ LIMIT 50
48
+ """,
49
+ (user_id,),
50
+ ).fetchall()
51
+
52
+ scored = []
53
+ for row in rows:
54
+ content = row["content"]
55
+ terms = set(tokenize(content))
56
+ score = len(query_terms & terms)
57
+ if score > 0:
58
+ scored.append((score, row))
59
+
60
+ scored.sort(key=lambda item: item[0], reverse=True)
61
+ selected = [row for _, row in scored[:limit]]
62
+ if not selected:
63
+ selected = rows[: min(limit, len(rows))]
64
+
65
+ if not selected and not langmem_lines:
66
+ return "No prior memory for this user."
67
+
68
+ lines = []
69
+ lines.extend(langmem_lines)
70
+ for row in selected:
71
+ lines.append(f"- {row['kind']}: {row['content']}")
72
+ return "\n".join(lines)
73
+
74
+ def save_interaction(
75
+ self,
76
+ user_id: str,
77
+ query: str,
78
+ answer: str,
79
+ critique: dict[str, Any],
80
+ sources: list[dict[str, Any]],
81
+ ) -> None:
82
+ decision = self._extract_decision(answer)
83
+ content = (
84
+ f"Scenario: {query} | Decision: {decision or 'unknown'} | "
85
+ f"Confidence: {critique.get('confidence', 0.0):.2f}"
86
+ )
87
+ metadata = {
88
+ "decision": decision,
89
+ "self_rag": {
90
+ "isrel": critique.get("isrel"),
91
+ "issup": critique.get("issup"),
92
+ "isuse": critique.get("isuse"),
93
+ },
94
+ "source_names": sorted({s.get("source_name", "unknown") for s in sources}),
95
+ }
96
+ self._insert_memory(user_id, "claim_interaction", content, metadata)
97
+ self._put_langmem_store(user_id, content, metadata)
98
+
99
+ def _search_langmem_store(self, user_id: str, query: str, limit: int) -> list[str]:
100
+ if not self.store:
101
+ return []
102
+ try:
103
+ items = self.store.search(("claim_support_memories", user_id), query=query, limit=limit)
104
+ lines = []
105
+ for item in items:
106
+ value = item.value or {}
107
+ content = value.get("content")
108
+ if content:
109
+ lines.append(f"- langmem: {content}")
110
+ return lines
111
+ except Exception:
112
+ return []
113
+
114
+ def _put_langmem_store(self, user_id: str, content: str, metadata: dict[str, Any]) -> None:
115
+ if not self.store:
116
+ return
117
+ try:
118
+ self.store.put(
119
+ ("claim_support_memories", user_id),
120
+ key=new_id("memory"),
121
+ value={"content": content, "metadata": metadata},
122
+ )
123
+ except Exception:
124
+ return
125
+
126
+ def _insert_memory(self, user_id: str, kind: str, content: str, metadata: dict[str, Any]) -> None:
127
+ normalized = normalize_text(content)
128
+ with db() as conn:
129
+ existing = conn.execute(
130
+ """
131
+ SELECT memory_id FROM memories
132
+ WHERE user_id = ? AND kind = ? AND content = ?
133
+ """,
134
+ (user_id, kind, content),
135
+ ).fetchone()
136
+ if existing:
137
+ return
138
+ similar = conn.execute(
139
+ """
140
+ SELECT memory_id FROM memories
141
+ WHERE user_id = ? AND kind = ? AND lower(content) = ?
142
+ """,
143
+ (user_id, kind, normalized),
144
+ ).fetchone()
145
+ if similar:
146
+ return
147
+ conn.execute(
148
+ """
149
+ INSERT INTO memories(memory_id, user_id, kind, content, metadata_json)
150
+ VALUES (?, ?, ?, ?, ?)
151
+ """,
152
+ (new_id("memory"), user_id, kind, content, json.dumps(metadata, ensure_ascii=True)),
153
+ )
154
+
155
+ def _extract_decision(self, answer: str) -> str | None:
156
+ match = re.search(r"Decision:\s*(.+)", answer, flags=re.IGNORECASE)
157
+ if not match:
158
+ return None
159
+ return match.group(1).strip().rstrip(".")
data/eval/golden_claim_scenarios.jsonl ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"id":"auto_deer_comprehensive","query":"I hit a deer and my car is damaged. Which part of my auto insurance covers this, and is it likely covered?","expected_decision":"Likely covered","must_have_sources":true,"reference":"Animal strikes are listed under comprehensive auto coverage in the coverage matrix, so this is likely covered if comprehensive coverage is active and the deductible applies."}
2
+ {"id":"auto_stolen_car_comprehensive","query":"My car was stolen from my driveway. I have a police report. Is this likely covered by auto insurance?","expected_decision":"Likely covered","acceptable_decisions":["Likely covered","Needs human review"],"must_have_sources":true,"reference":"Vehicle theft is listed under comprehensive auto coverage. The claim still needs policy verification, deductible review, and theft documentation such as a police report."}
3
+ {"id":"auto_hail_damage_comprehensive","query":"A hailstorm damaged my parked car. I have photos and a repair estimate. Will insurance likely pay?","expected_decision":"Likely covered","acceptable_decisions":["Likely covered","Needs human review"],"must_have_sources":true,"reference":"Hail damage to a car is listed under comprehensive auto coverage. Payment depends on active comprehensive coverage, deductible, photos, and repair estimate."}
4
+ {"id":"auto_rental_after_accident","query":"My car is in the shop after an accident and repairs will take three weeks. Will insurance pay for my rental car?","expected_decision":"Needs human review","must_have_sources":true,"reference":"Rental reimbursement depends on whether the insured purchased rental coverage and who was at fault. The user should confirm daily and maximum rental limits and keep receipts."}
5
+ {"id":"basement_flood_heavy_rain","query":"My basement flooded after heavy rain and water came through a foundation crack. Will homeowners insurance cover it?","expected_decision":"Likely not covered","acceptable_decisions":["Likely not covered","Needs human review"],"must_have_sources":true,"reference":"Rainwater entering through a foundation crack is likely flood or surface water, which standard homeowners policies exclude unless separate flood coverage applies."}
6
+ {"id":"basement_sump_backup","query":"My basement flooded because the sump pump failed during a storm. I am not sure if I bought sewer or drain backup coverage. Is this covered?","expected_decision":"Needs human review","must_have_sources":true,"reference":"Sump pump or drain backup may require a sewer/drain backup endorsement. Coverage depends on whether that endorsement exists and the source of water."}
7
+ {"id":"pipe_burst_homeowners","query":"A pipe burst in my home and caused water damage to the floor. I documented the damage and called a plumber. Is this likely covered?","expected_decision":"Likely covered","acceptable_decisions":["Likely covered","Needs human review"],"must_have_sources":true,"reference":"Water damage from a burst pipe is typically covered under the standard plumbing water damage peril, subject to policy terms, documentation, and deductible."}
8
+ {"id":"earthquake_standard_policy","query":"Will earthquake damage be covered under a standard homeowners policy?","expected_decision":"Likely not covered","acceptable_decisions":["Likely not covered","Needs human review"],"must_have_sources":true,"reference":"Earthquake is listed as a major exclusion and normally requires a separate earthquake endorsement or policy."}
9
+ {"id":"jewelry_stolen_hotel","query":"My diamond engagement ring was stolen from a hotel room. I have a police report, but the ring is worth more than my normal renters policy limit. Is it covered?","expected_decision":"Needs human review","must_have_sources":true,"reference":"Renters insurance may cover theft, but jewelry often has a sublimit such as $1,500 unless a scheduled personal property endorsement was purchased."}
10
+ {"id":"life_contestability_period","query":"My husband died eight months after buying a life insurance policy and the insurer is investigating. Is that normal and will the claim be paid?","expected_decision":"Needs human review","must_have_sources":true,"reference":"Life policies commonly have a two-year contestability period. Investigation is normal during that period, and payment depends on whether there was material misrepresentation or other exclusion."}
11
+ {"id":"bad_faith_long_investigation","query":"My insurer has been investigating my property claim for six months with no decision after I submitted proof of loss. What should happen?","expected_decision":"Needs human review","must_have_sources":true,"reference":"Most states require timely claim handling after complete proof of loss. A six-month delay may require escalation, written demand, DOI complaint, or legal review."}
12
+ {"id":"inflated_repair_bill_red_flag","query":"The contractor invoice is much higher than the visible damage in my photos. Should this claim be approved now?","expected_decision":"Needs human review","must_have_sources":true,"reference":"An inflated repair bill or mismatch between claimed damage and photos is a fraud red flag. The file should be reviewed before approval and may require SIU or adjuster investigation."}
13
+ {"id":"guaranty_property_casualty_limit","query":"My property casualty claim is $600,000, but my insurer became insolvent. How much may the state guaranty fund protect?","expected_decision":"Needs human review","must_have_sources":true,"reference":"The knowledge base lists typical property/casualty guaranty fund protection around $300,000 to $500,000 per claim, but exact limits vary by state and require review."}
14
+ {"id":"total_loss_gap_insurance","query":"My insurer declared my car a total loss and offered $12,000, but I still owe $16,000 on the loan. Will insurance pay the full loan balance?","expected_decision":"Needs human review","must_have_sources":true,"reference":"A total loss settlement is usually based on actual cash value. Gap insurance may cover the difference between ACV and the remaining loan balance if purchased."}
data/sample_insurance_claim_guide.pdf ADDED
The diff for this file is too large to render. See raw diff
 
docker-compose.yml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ api:
3
+ build: .
4
+ container_name: insurance-copilot-api
5
+ ports:
6
+ - "8000:8000"
7
+ env_file:
8
+ - .env
9
+ environment:
10
+ QDRANT_URL: http://qdrant:6333
11
+ QDRANT_API_KEY: ""
12
+ APP_HOST: 0.0.0.0
13
+ APP_PORT: 8000
14
+ DOCUMENT_DIR: /app/data
15
+ UPLOAD_DIR: /app/data/uploads
16
+ SQLITE_PATH: /app/data/copilot.db
17
+ BM25_INDEX_PATH: /app/data/bm25_index.json
18
+ ENABLE_QUERY_REWRITE: "true"
19
+ depends_on:
20
+ - qdrant
21
+ volumes:
22
+ - ./data:/app/data
23
+ - huggingface_cache:/app/.cache/huggingface
24
+ restart: unless-stopped
25
+
26
+ qdrant:
27
+ image: qdrant/qdrant:latest
28
+ container_name: insurance-copilot-qdrant
29
+ ports:
30
+ - "6333:6333"
31
+ - "6334:6334"
32
+ volumes:
33
+ - qdrant_storage:/qdrant/storage
34
+ restart: unless-stopped
35
+
36
+ volumes:
37
+ qdrant_storage:
38
+ huggingface_cache:
frontend/assets/app.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const messages = document.getElementById("messages");
2
+ const form = document.getElementById("chatForm");
3
+ const input = document.getElementById("queryInput");
4
+ const sendBtn = document.getElementById("sendBtn");
5
+ const sourcesPanel = document.getElementById("sourcesPanel");
6
+ const sourcesBox = document.getElementById("sources");
7
+ const latency = document.getElementById("latency");
8
+ const sourceCount = document.getElementById("sourceCount");
9
+
10
+ function appendMessage(role, text) {
11
+ const article = document.createElement("article");
12
+ article.className = `message ${role}`;
13
+ const bubble = document.createElement("div");
14
+ bubble.className = "bubble";
15
+ bubble.textContent = text;
16
+ article.appendChild(bubble);
17
+ messages.appendChild(article);
18
+ messages.scrollTop = messages.scrollHeight;
19
+ return bubble;
20
+ }
21
+
22
+ function renderSources(data) {
23
+ const sources = data.sources || [];
24
+ latency.textContent = `Latency: ${Math.round(data.latency_ms || 0)} ms`;
25
+ sourceCount.textContent = `Sources: ${sources.length}`;
26
+ sourcesPanel.hidden = false;
27
+
28
+ if (!sources.length) {
29
+ sourcesBox.innerHTML = "<p class=\"empty-source\">No source was returned for this answer.</p>";
30
+ return;
31
+ }
32
+
33
+ sourcesBox.innerHTML = sources.map((source, index) => {
34
+ const page = source.page === undefined || source.page === null ? "" : ` · page ${Number(source.page) + 1}`;
35
+ const score = source.score ? ` · score ${Number(source.score).toFixed(2)}` : "";
36
+ return `
37
+ <article class="source">
38
+ <strong>${index + 1}. ${escapeHtml(source.source_name || "unknown")}${page}${score}</strong>
39
+ <p>${escapeHtml((source.text || "").slice(0, 360))}</p>
40
+ </article>
41
+ `;
42
+ }).join("");
43
+ }
44
+
45
+ async function ask(query) {
46
+ appendMessage("user", query);
47
+ const pending = appendMessage("assistant", "Thinking...");
48
+ sendBtn.disabled = true;
49
+
50
+ try {
51
+ const response = await fetch("/api/query", {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({ query }),
55
+ });
56
+ const data = await response.json();
57
+ if (!response.ok) throw new Error(data.detail || "Request failed");
58
+ pending.textContent = data.answer || "No answer returned.";
59
+ renderSources(data);
60
+ } catch (error) {
61
+ pending.textContent = error.message;
62
+ } finally {
63
+ sendBtn.disabled = false;
64
+ input.focus();
65
+ }
66
+ }
67
+
68
+ function escapeHtml(value) {
69
+ return String(value)
70
+ .replaceAll("&", "&amp;")
71
+ .replaceAll("<", "&lt;")
72
+ .replaceAll(">", "&gt;")
73
+ .replaceAll('"', "&quot;")
74
+ .replaceAll("'", "&#039;");
75
+ }
76
+
77
+ form.addEventListener("submit", (event) => {
78
+ event.preventDefault();
79
+ const query = input.value.trim();
80
+ if (!query) return;
81
+ input.value = "";
82
+ ask(query);
83
+ });
frontend/assets/styles.css ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #071015;
3
+ --panel: rgba(13, 27, 35, 0.88);
4
+ --panel-soft: rgba(18, 38, 48, 0.78);
5
+ --line: rgba(103, 232, 221, 0.22);
6
+ --text: #ecfbff;
7
+ --muted: #91aab2;
8
+ --cyan: #67e8dd;
9
+ --green: #a7f3c4;
10
+ }
11
+
12
+ * {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ margin: 0;
18
+ min-height: 100vh;
19
+ color: var(--text);
20
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
+ background:
22
+ linear-gradient(rgba(103, 232, 221, 0.035) 1px, transparent 1px),
23
+ linear-gradient(90deg, rgba(103, 232, 221, 0.035) 1px, transparent 1px),
24
+ radial-gradient(circle at 50% 0%, rgba(103, 232, 221, 0.14), transparent 34rem),
25
+ var(--bg);
26
+ background-size: 36px 36px, 36px 36px, auto, auto;
27
+ }
28
+
29
+ button,
30
+ input {
31
+ font: inherit;
32
+ }
33
+
34
+ .chat-shell {
35
+ min-height: 100vh;
36
+ display: grid;
37
+ place-items: center;
38
+ padding: 28px;
39
+ }
40
+
41
+ .chat-card {
42
+ width: min(980px, 100%);
43
+ height: min(820px, calc(100vh - 56px));
44
+ display: grid;
45
+ grid-template-rows: auto 1fr auto auto;
46
+ border: 1px solid var(--line);
47
+ background: var(--panel);
48
+ box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
49
+ }
50
+
51
+ .chat-header {
52
+ display: flex;
53
+ justify-content: space-between;
54
+ align-items: center;
55
+ gap: 16px;
56
+ padding: 22px;
57
+ border-bottom: 1px solid var(--line);
58
+ }
59
+
60
+ .chat-header p,
61
+ .chat-header h1 {
62
+ margin: 0;
63
+ }
64
+
65
+ .chat-header p {
66
+ color: var(--cyan);
67
+ font-size: 12px;
68
+ font-weight: 800;
69
+ letter-spacing: 0.12em;
70
+ text-transform: uppercase;
71
+ }
72
+
73
+ .chat-header h1 {
74
+ margin-top: 4px;
75
+ font-size: clamp(24px, 4vw, 40px);
76
+ letter-spacing: 0;
77
+ }
78
+
79
+ .status {
80
+ border: 1px solid rgba(167, 243, 196, 0.34);
81
+ color: var(--green);
82
+ padding: 8px 10px;
83
+ white-space: nowrap;
84
+ background: rgba(167, 243, 196, 0.07);
85
+ }
86
+
87
+ .messages {
88
+ overflow: auto;
89
+ padding: 22px;
90
+ }
91
+
92
+ .message {
93
+ display: flex;
94
+ margin-bottom: 14px;
95
+ }
96
+
97
+ .message.user {
98
+ justify-content: flex-end;
99
+ }
100
+
101
+ .bubble {
102
+ max-width: min(720px, 88%);
103
+ padding: 14px 16px;
104
+ line-height: 1.6;
105
+ white-space: pre-wrap;
106
+ border: 1px solid rgba(145, 170, 178, 0.22);
107
+ }
108
+
109
+ .assistant .bubble {
110
+ background: rgba(9, 19, 25, 0.82);
111
+ }
112
+
113
+ .user .bubble {
114
+ background: rgba(103, 232, 221, 0.12);
115
+ border-color: rgba(103, 232, 221, 0.34);
116
+ }
117
+
118
+ .sources-panel {
119
+ border-top: 1px solid var(--line);
120
+ background: rgba(4, 12, 16, 0.48);
121
+ padding: 14px 22px;
122
+ }
123
+
124
+ .meta-row {
125
+ display: flex;
126
+ gap: 10px;
127
+ flex-wrap: wrap;
128
+ margin-bottom: 10px;
129
+ }
130
+
131
+ .meta-row span {
132
+ border: 1px solid var(--line);
133
+ color: #c9f7f4;
134
+ background: rgba(103, 232, 221, 0.07);
135
+ padding: 7px 9px;
136
+ font-size: 13px;
137
+ }
138
+
139
+ .sources {
140
+ display: grid;
141
+ gap: 8px;
142
+ max-height: 170px;
143
+ overflow: auto;
144
+ }
145
+
146
+ .source {
147
+ border: 1px solid rgba(145, 170, 178, 0.2);
148
+ background: var(--panel-soft);
149
+ padding: 10px;
150
+ }
151
+
152
+ .source strong {
153
+ color: var(--cyan);
154
+ font-size: 13px;
155
+ }
156
+
157
+ .source p,
158
+ .empty-source {
159
+ margin: 7px 0 0;
160
+ color: #d5e8ec;
161
+ line-height: 1.45;
162
+ font-size: 14px;
163
+ }
164
+
165
+ .composer {
166
+ display: grid;
167
+ grid-template-columns: 1fr auto;
168
+ gap: 10px;
169
+ padding: 18px 22px 22px;
170
+ border-top: 1px solid var(--line);
171
+ }
172
+
173
+ .composer input {
174
+ min-height: 48px;
175
+ border: 1px solid rgba(145, 170, 178, 0.28);
176
+ background: #08161d;
177
+ color: var(--text);
178
+ outline: none;
179
+ padding: 0 14px;
180
+ }
181
+
182
+ .composer input:focus {
183
+ border-color: var(--cyan);
184
+ box-shadow: 0 0 0 3px rgba(103, 232, 221, 0.1);
185
+ }
186
+
187
+ .composer button {
188
+ min-width: 104px;
189
+ border: 1px solid rgba(103, 232, 221, 0.55);
190
+ color: var(--text);
191
+ background: linear-gradient(135deg, #0fb9b1, #176e84);
192
+ cursor: pointer;
193
+ font-weight: 800;
194
+ }
195
+
196
+ .composer button:disabled {
197
+ opacity: 0.65;
198
+ cursor: wait;
199
+ }
200
+
201
+ @media (max-width: 640px) {
202
+ .chat-shell {
203
+ padding: 0;
204
+ }
205
+
206
+ .chat-card {
207
+ min-height: 100vh;
208
+ height: 100vh;
209
+ border: 0;
210
+ }
211
+
212
+ .chat-header {
213
+ align-items: flex-start;
214
+ flex-direction: column;
215
+ }
216
+
217
+ .composer {
218
+ grid-template-columns: 1fr;
219
+ }
220
+
221
+ .composer button {
222
+ min-height: 46px;
223
+ }
224
+ }
frontend/index.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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" />
6
+ <title>Insurance Copilot</title>
7
+ <link rel="stylesheet" href="/assets/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="chat-shell">
11
+ <section class="chat-card">
12
+ <header class="chat-header">
13
+ <div>
14
+ <p>Insurance Copilot</p>
15
+ <h1>Ask about insurance</h1>
16
+ </div>
17
+ <span class="status">RAG ready</span>
18
+ </header>
19
+
20
+ <div id="messages" class="messages">
21
+ <article class="message assistant">
22
+ <div class="bubble">
23
+ Describe your claim scenario and I will triage whether it looks likely covered, likely not covered, or needs human review.
24
+ </div>
25
+ </article>
26
+ </div>
27
+
28
+ <section id="sourcesPanel" class="sources-panel" hidden>
29
+ <div class="meta-row">
30
+ <span id="latency">Latency: --</span>
31
+ <span id="sourceCount">Sources: 0</span>
32
+ </div>
33
+ <div id="sources" class="sources"></div>
34
+ </section>
35
+
36
+ <form id="chatForm" class="composer">
37
+ <input
38
+ id="queryInput"
39
+ type="text"
40
+ autocomplete="off"
41
+ placeholder="Describe what happened in your claim..."
42
+ />
43
+ <button id="sendBtn" type="submit">Send</button>
44
+ </form>
45
+ </section>
46
+ </main>
47
+
48
+ <script src="/assets/app.js"></script>
49
+ </body>
50
+ </html>
requirements-docker.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ python-dotenv>=1.0.1
4
+ pydantic>=2.8.0
5
+ pydantic-settings>=2.4.0
6
+ python-multipart>=0.0.9
7
+ qdrant-client>=1.11.0
8
+ rank-bm25>=0.2.2
9
+ flashrank>=0.2.10
10
+ langchain==1.2.18
11
+ langchain-core==1.4.0
12
+ langchain-community==0.4.1
13
+ langchain-groq==1.1.2
14
+ langchain-huggingface==1.2.2
15
+ langchain-text-splitters==1.1.2
16
+ langgraph==1.1.10
17
+ langgraph-prebuilt==1.0.13
18
+ langmem==0.0.30
19
+ sentence-transformers>=3.0.0
20
+ pypdf>=5.0.0
21
+ numpy>=1.26.0
22
+ orjson>=3.10.0
scripts/generate_sample_claim_pdf.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from textwrap import wrap
3
+
4
+
5
+ PAGE_WIDTH = 612
6
+ PAGE_HEIGHT = 792
7
+ LEFT = 50
8
+ TOP = 760
9
+ LINE_HEIGHT = 14
10
+ MAX_LINES_PER_PAGE = 46
11
+ WRAP_WIDTH = 92
12
+ PAGE_COUNT = 50
13
+
14
+
15
+ AGENT_TOPICS = [
16
+ (
17
+ "Claim Support Agent Mission",
18
+ "The insurance claim support AI agent helps customers and support adjusters reason through "
19
+ "claim scenarios. The agent should not merely define insurance terms. It should ask what "
20
+ "happened, identify the likely claim type, retrieve relevant policy and procedure evidence, "
21
+ "consider prior user context from memory, and explain whether the claim appears likely covered, "
22
+ "likely not covered, or uncertain. The agent must avoid final binding coverage decisions unless "
23
+ "the policy and claim file clearly support the conclusion. When evidence is incomplete, the "
24
+ "agent should list missing information and recommend human review.",
25
+ ),
26
+ (
27
+ "Scenario Based Claim Reasoning",
28
+ "A scenario-based response begins by restating the facts that matter: cause of loss, date of "
29
+ "loss, property or vehicle involved, policy type, available evidence, mitigation steps, and any "
30
+ "red flags. The agent then maps the scenario to a claim category such as water damage, theft, "
31
+ "fire, auto collision, liability, flood, storm, or personal property. It should compare the "
32
+ "scenario with retrieved claim rules and explain the likely outcome as likely covered, likely "
33
+ "not covered, or needs review. The agent should include citations to retrieved sources and "
34
+ "should clearly separate evidence-based conclusions from assumptions.",
35
+ ),
36
+ (
37
+ "Memory Usage With LangMem",
38
+ "The agent should use memory to personalize support without exposing sensitive information. "
39
+ "Useful memory includes the customer's previous claim type, preferred contact method, recurring "
40
+ "missing documents, prior escalation outcomes, and approved resolution summaries. Memory should "
41
+ "not replace retrieval from policy documents. If memory says the customer previously had a water "
42
+ "claim with missing mitigation invoices, the agent may remind the user that mitigation evidence "
43
+ "was important before, but it must still retrieve current policy guidance before making a coverage "
44
+ "recommendation. Approved human resolutions are stronger memory than unreviewed draft answers.",
45
+ ),
46
+ (
47
+ "Tool Calling Policy",
48
+ "The agent can call tools when the answer depends on external operational data. A claim lookup "
49
+ "tool should be used to check claim status, date of loss, assigned adjuster, missing documents, "
50
+ "and previous notes. A plan lookup tool should be used to check policy limits, endorsements, "
51
+ "deductibles, covered property, and exclusions. An open ticket load tool should be used to decide "
52
+ "whether to route the matter to a human support queue. The agent should state which tool would be "
53
+ "useful and why when a tool result is needed but unavailable.",
54
+ ),
55
+ (
56
+ "Coverage Decision Labels",
57
+ "The agent should use cautious labels. 'Likely covered' means the retrieved evidence supports "
58
+ "coverage and no obvious exclusion appears in the provided scenario. 'Likely not covered' means "
59
+ "the retrieved evidence points to an exclusion or unmet condition. 'Needs human review' means "
60
+ "evidence is missing, policy language is ambiguous, the scenario is high risk, or a tool lookup is "
61
+ "required. These labels are support recommendations, not final legal or contractual decisions.",
62
+ ),
63
+ (
64
+ "Water Damage Scenario Rules",
65
+ "Water damage scenarios require attention to cause and timing. Sudden and accidental discharge "
66
+ "from a burst pipe may be treated more favorably than seepage, repeated leakage, mold, or poor "
67
+ "maintenance. Required evidence often includes notice of loss, photos, plumber report, repair "
68
+ "estimate, mitigation invoice, and proof that the policy was active. If the customer says water "
69
+ "leaked slowly for months, the agent should mark the claim as likely not covered or needs human "
70
+ "review because gradual leakage and maintenance issues may be excluded.",
71
+ ),
72
+ (
73
+ "Flood and Storm Scenario Rules",
74
+ "Flood scenarios should be separated from internal water damage. Heavy rain entering from surface "
75
+ "water, storm surge, overflowing bodies of water, or groundwater may require separate flood coverage. "
76
+ "Wind or hail damage may be handled differently from flood damage. If a customer says the basement "
77
+ "flooded after heavy rain, the agent should not promise coverage under a standard property policy. "
78
+ "It should recommend plan lookup for flood endorsement or separate flood policy and request photos, "
79
+ "weather date, water entry point, and mitigation records.",
80
+ ),
81
+ (
82
+ "Theft Scenario Rules",
83
+ "Theft scenarios require a police report, list of stolen items, proof of ownership, receipts, serial "
84
+ "numbers, and photos or security footage when available. If property was stolen from an unlocked car, "
85
+ "the agent should check whether the property policy or auto policy applies and whether limitations "
86
+ "or exclusions apply. High-value items such as jewelry, electronics, collectibles, firearms, and art "
87
+ "may have sublimits or scheduled property requirements. Missing police report or ownership proof "
88
+ "should trigger human review.",
89
+ ),
90
+ (
91
+ "Fire and Smoke Scenario Rules",
92
+ "Fire and smoke scenarios require fire department report, photos, repair estimate, damaged-property "
93
+ "inventory, proof of ownership for valuable items, and temporary housing receipts if additional living "
94
+ "expense is claimed. Suspected arson, inconsistent timelines, missing fire report, or unusually high "
95
+ "claimed values should trigger escalation. Smoke damage should be described separately from direct "
96
+ "fire damage because cleaning and odor remediation may require different documentation.",
97
+ ),
98
+ (
99
+ "Auto Collision Scenario Rules",
100
+ "Auto collision scenarios require accident date and location, driver details, vehicle photos, repair "
101
+ "estimate, registration, insurance information for involved parties, witness details, and police report "
102
+ "when available. If there is no police report, the claim may still proceed but needs stronger supporting "
103
+ "evidence. Liability depends on statements, traffic rules, point of impact, photos, and police report. "
104
+ "Total loss review requires actual cash value, title status, lienholder details, and state rules.",
105
+ ),
106
+ (
107
+ "Liability Scenario Rules",
108
+ "Liability scenarios involve allegations that the insured caused bodily injury or property damage to "
109
+ "another person. The agent should not admit fault. It should request incident description, claimant "
110
+ "contact details, photos, witness statements, medical bills for bodily injury, property repair invoices, "
111
+ "and any demand letter. Bodily injury, attorney involvement, policy limit demand, or legal threat should "
112
+ "trigger human review and possible specialist routing.",
113
+ ),
114
+ (
115
+ "Human Review Triggers",
116
+ "Human review is required when evidence is missing, documents appear altered, claim facts conflict, "
117
+ "policy language is unclear, the user asks for a denial or appeal decision, legal threats are present, "
118
+ "bodily injury is involved, fraud indicators appear, or high-value property is claimed without proof. "
119
+ "The agent should explain the reason for escalation in plain language and list the next best action.",
120
+ ),
121
+ (
122
+ "Fraud and Risk Signals",
123
+ "Risk signals include loss shortly after policy inception, duplicate receipts, altered invoices, refusal "
124
+ "to permit inspection, repair estimates that do not match photos, multiple similar claims, staged accident "
125
+ "concerns, missing ownership proof, inconsistent timelines, or pressure for immediate payment. Risk signals "
126
+ "do not prove fraud, but they justify additional documentation and senior review.",
127
+ ),
128
+ (
129
+ "Recommended Answer Format",
130
+ "For claim scenarios, the recommended answer format is: decision label, short reasoning, needed evidence, "
131
+ "tool or memory action, and source citation. Example labels are likely covered, likely not covered, and "
132
+ "needs human review. The agent should avoid long legal explanations unless requested. It should be concise, "
133
+ "helpful, and transparent about uncertainty.",
134
+ ),
135
+ ]
136
+
137
+
138
+ SCENARIOS = [
139
+ (
140
+ "My basement flooded after heavy rain and water came through the floor drain. Will insurance pay?",
141
+ "Needs human review. This may involve flood, surface water, sewer backup, or storm water conditions. "
142
+ "The agent should call plan lookup to check flood or sewer backup endorsement and request photos, "
143
+ "water entry point, weather date, and mitigation records.",
144
+ ),
145
+ (
146
+ "A pipe suddenly burst in my kitchen while I was away for work. I have photos and a plumber report.",
147
+ "Likely covered if the policy covers sudden and accidental water discharge and no exclusion applies. "
148
+ "The agent should request mitigation invoices, repair estimates, date of loss, and policy verification.",
149
+ ),
150
+ (
151
+ "My bathroom leaked slowly for months and now there is mold behind the wall.",
152
+ "Likely not covered or needs human review because gradual leakage, mold, and maintenance issues may be "
153
+ "excluded. The agent should retrieve water damage exclusions and request contractor findings.",
154
+ ),
155
+ (
156
+ "My laptop and camera were stolen from my unlocked car.",
157
+ "Needs human review. The agent should check whether property or auto coverage applies, ask for a police "
158
+ "report, proof of ownership, receipts, serial numbers, and review sublimits for electronics.",
159
+ ),
160
+ (
161
+ "A small kitchen fire damaged cabinets and smoke damaged furniture.",
162
+ "Likely covered if fire is a covered peril and no exclusion applies. Required evidence includes fire "
163
+ "report, photos, repair estimate, smoke remediation estimate, inventory, and receipts.",
164
+ ),
165
+ (
166
+ "I hit another car but there is no police report. Can I still claim?",
167
+ "Needs review but may proceed with other evidence. The agent should request photos, driver information, "
168
+ "repair estimate, witness details, accident location, and statement of events.",
169
+ ),
170
+ (
171
+ "A guest slipped on my stairs and is asking me to pay medical bills.",
172
+ "Needs human review. Bodily injury liability matters should be escalated. The agent should request incident "
173
+ "description, photos, witness statements, medical bills, and any demand letter.",
174
+ ),
175
+ (
176
+ "My roof was damaged by hail during a storm.",
177
+ "Potentially covered depending on policy and evidence. The agent should request photos, contractor estimate, "
178
+ "weather date, inspection notes, and plan lookup for wind or hail coverage and deductible.",
179
+ ),
180
+ (
181
+ "My jewelry was stolen but I do not have receipts.",
182
+ "Needs human review. Jewelry may have sublimits or scheduled property requirements. The agent should request "
183
+ "police report, photos, appraisal, bank records, or other proof of ownership.",
184
+ ),
185
+ (
186
+ "The repair invoice looks higher than the visible damage in photos.",
187
+ "Needs human review because invoice and photo mismatch is a risk signal. The agent should request itemized "
188
+ "estimate, inspection, and senior adjuster review.",
189
+ ),
190
+ ]
191
+
192
+
193
+ def escape_pdf_text(text: str) -> str:
194
+ return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
195
+
196
+
197
+ def paragraph_lines(text: str) -> list[str]:
198
+ lines: list[str] = []
199
+ for paragraph in text.split("\n"):
200
+ if not paragraph.strip():
201
+ lines.append("")
202
+ continue
203
+ lines.extend(wrap(paragraph, width=WRAP_WIDTH))
204
+ return lines
205
+
206
+
207
+ def make_page(page_number: int) -> list[str]:
208
+ topic = AGENT_TOPICS[(page_number - 1) % len(AGENT_TOPICS)]
209
+ related = AGENT_TOPICS[page_number % len(AGENT_TOPICS)]
210
+ scenario = SCENARIOS[(page_number - 1) % len(SCENARIOS)]
211
+ second_scenario = SCENARIOS[page_number % len(SCENARIOS)]
212
+
213
+ body = (
214
+ f"Insurance Claim Support AI Agent with LangMem and RAG - Page {page_number:02d}\n\n"
215
+ f"{topic[0]}\n"
216
+ f"{topic[1]}\n\n"
217
+ f"RAG guidance: Retrieve policy rules, claim procedures, and prior approved resolutions before "
218
+ f"answering. If retrieved evidence is weak, say that evidence is insufficient. Cite retrieved "
219
+ f"sources. Do not invent policy terms, claim status, payment approval, or denial decisions.\n\n"
220
+ f"Memory guidance: Use LangMem-style memory for prior user interactions, repeated missing documents, "
221
+ f"preferred contact method, and approved claim resolutions. Memory may personalize the answer, but "
222
+ f"policy retrieval and tool results should control coverage reasoning.\n\n"
223
+ f"Tool guidance: Use claim lookup for claim status and missing documents. Use plan lookup for coverage, "
224
+ f"limits, deductibles, endorsements, and exclusions. Use ticket load or escalation tools when the case "
225
+ f"requires human review or specialist routing.\n\n"
226
+ f"Scenario example: {scenario[0]}\n"
227
+ f"Expected agent response: {scenario[1]}\n\n"
228
+ f"Additional scenario: {second_scenario[0]}\n"
229
+ f"Expected agent response: {second_scenario[1]}\n\n"
230
+ f"Related topic: {related[0]}. {related[1]}\n\n"
231
+ f"Recommended response structure: Decision label, explanation, missing evidence, recommended tool call, "
232
+ f"human review decision, and source citation."
233
+ )
234
+
235
+ lines = paragraph_lines(body)
236
+ if len(lines) > MAX_LINES_PER_PAGE:
237
+ return lines[:MAX_LINES_PER_PAGE]
238
+ return lines + [""] * (MAX_LINES_PER_PAGE - len(lines))
239
+
240
+
241
+ def page_stream(lines: list[str]) -> bytes:
242
+ content_lines = ["BT", "/F1 10 Tf", f"{LEFT} {TOP} Td", f"{LINE_HEIGHT} TL"]
243
+ for index, line in enumerate(lines):
244
+ escaped = escape_pdf_text(line)
245
+ if index == 0:
246
+ content_lines.append(f"({escaped}) Tj")
247
+ else:
248
+ content_lines.append(f"T* ({escaped}) Tj")
249
+ content_lines.append("ET")
250
+ return "\n".join(content_lines).encode("latin-1", errors="replace")
251
+
252
+
253
+ def build_pdf(pages: list[list[str]]) -> bytes:
254
+ objects: list[bytes] = []
255
+ pages_id = 2
256
+ font_id = 3
257
+ page_ids: list[int] = []
258
+ content_ids: list[int] = []
259
+
260
+ next_id = 4
261
+ for _ in pages:
262
+ page_ids.append(next_id)
263
+ next_id += 1
264
+ content_ids.append(next_id)
265
+ next_id += 1
266
+
267
+ kids = " ".join(f"{page_id} 0 R" for page_id in page_ids)
268
+ objects.append(f"<< /Type /Catalog /Pages {pages_id} 0 R >>".encode("ascii"))
269
+ objects.append(f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode("ascii"))
270
+ objects.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
271
+
272
+ for page_id, content_id, page_lines in zip(page_ids, content_ids, pages):
273
+ objects.append(
274
+ (
275
+ f"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {PAGE_WIDTH} {PAGE_HEIGHT}] "
276
+ f"/Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>"
277
+ ).encode("ascii")
278
+ )
279
+ content = page_stream(page_lines)
280
+ objects.append(
281
+ b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"\nendstream"
282
+ )
283
+
284
+ pdf = bytearray(b"%PDF-1.4\n")
285
+ offsets = [0]
286
+ for obj_id, body in enumerate(objects, start=1):
287
+ offsets.append(len(pdf))
288
+ pdf.extend(f"{obj_id} 0 obj\n".encode("ascii"))
289
+ pdf.extend(body)
290
+ pdf.extend(b"\nendobj\n")
291
+
292
+ xref_start = len(pdf)
293
+ pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
294
+ pdf.extend(b"0000000000 65535 f \n")
295
+ for offset in offsets[1:]:
296
+ pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
297
+ pdf.extend(
298
+ (
299
+ f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
300
+ f"startxref\n{xref_start}\n%%EOF\n"
301
+ ).encode("ascii")
302
+ )
303
+ return bytes(pdf)
304
+
305
+
306
+ def main() -> None:
307
+ pages = [make_page(page_number) for page_number in range(1, PAGE_COUNT + 1)]
308
+ output = Path("data") / "sample_insurance_claim_guide.pdf"
309
+ output.parent.mkdir(parents=True, exist_ok=True)
310
+ output.write_bytes(build_pdf(pages))
311
+ print(output)
312
+
313
+
314
+ if __name__ == "__main__":
315
+ main()
scripts/run_eval.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import re
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from app.db.sqlite import init_db
14
+ from app.rag.graph import ClaimsRAGGraph
15
+ from app.rag.ingestion import DocumentIngestionService
16
+ from app.rag.qdrant_store import QdrantVectorStore
17
+
18
+
19
+ DECISION_RE = re.compile(r"Decision:\s*(Likely covered|Likely not covered|Needs human review)", re.I)
20
+
21
+
22
+ def load_jsonl(path: Path) -> list[dict[str, Any]]:
23
+ rows = []
24
+ for line in path.read_text(encoding="utf-8").splitlines():
25
+ if line.strip():
26
+ rows.append(json.loads(line))
27
+ return rows
28
+
29
+
30
+ def extract_decision(answer: str) -> str:
31
+ match = DECISION_RE.search(answer)
32
+ if not match:
33
+ return "Unknown"
34
+ return match.group(1).capitalize().replace("Not", "not")
35
+
36
+
37
+ def accepted_decisions(case: dict[str, Any]) -> set[str]:
38
+ values = case.get("acceptable_decisions") or [case["expected_decision"]]
39
+ return {str(v).lower() for v in values}
40
+
41
+
42
+ def evaluate(dataset_path: Path, user_id: str, limit: int | None = None) -> dict[str, Any]:
43
+ init_db()
44
+ QdrantVectorStore().ensure_collections()
45
+ DocumentIngestionService().ingest_pdf_directory()
46
+ graph = ClaimsRAGGraph()
47
+
48
+ cases = load_jsonl(dataset_path)
49
+ if limit:
50
+ cases = cases[:limit]
51
+ results = []
52
+ for case in cases:
53
+ started = time.perf_counter()
54
+ state = graph.run(case["query"], user_id=user_id, use_cache=False)
55
+ latency_ms = round((time.perf_counter() - started) * 1000, 2)
56
+ answer = state.get("answer", "")
57
+ decision = extract_decision(answer)
58
+ sources = state.get("reranked_sources") or state.get("sources", [])
59
+ critique = state.get("self_rag", {})
60
+ decision_ok = decision.lower() in accepted_decisions(case)
61
+ sources_ok = bool(sources) if case.get("must_have_sources", True) else True
62
+ self_rag_ok = bool(critique.get("isrel")) and bool(critique.get("issup")) and bool(critique.get("isuse"))
63
+ passed = decision_ok and sources_ok and self_rag_ok
64
+ results.append(
65
+ {
66
+ "id": case["id"],
67
+ "expected": case["expected_decision"],
68
+ "decision": decision,
69
+ "decision_ok": decision_ok,
70
+ "sources": len(sources),
71
+ "sources_ok": sources_ok,
72
+ "self_rag": {
73
+ "ISREL": critique.get("isrel"),
74
+ "ISSUP": critique.get("issup"),
75
+ "ISUSE": critique.get("isuse"),
76
+ },
77
+ "self_rag_ok": self_rag_ok,
78
+ "latency_ms": latency_ms,
79
+ "passed": passed,
80
+ }
81
+ )
82
+
83
+ total = len(results)
84
+ summary = {
85
+ "total": total,
86
+ "passed": sum(1 for r in results if r["passed"]),
87
+ "decision_accuracy": round(sum(1 for r in results if r["decision_ok"]) / total, 3) if total else 0,
88
+ "source_rate": round(sum(1 for r in results if r["sources_ok"]) / total, 3) if total else 0,
89
+ "self_rag_pass_rate": round(sum(1 for r in results if r["self_rag_ok"]) / total, 3) if total else 0,
90
+ "avg_latency_ms": round(sum(r["latency_ms"] for r in results) / total, 2) if total else 0,
91
+ "results": results,
92
+ }
93
+ return summary
94
+
95
+
96
+ def main() -> None:
97
+ parser = argparse.ArgumentParser()
98
+ parser.add_argument("--dataset", default="data/eval/golden_claim_scenarios.jsonl")
99
+ parser.add_argument("--user-id", default="eval_user")
100
+ parser.add_argument("--limit", type=int, default=None)
101
+ args = parser.parse_args()
102
+
103
+ summary = evaluate(Path(args.dataset), args.user_id, args.limit)
104
+ print(json.dumps(summary, indent=2))
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
scripts/run_ragas_eval.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from datasets import Dataset
14
+ from langchain_groq import ChatGroq
15
+ from ragas import RunConfig, evaluate
16
+ from ragas.embeddings import LangchainEmbeddingsWrapper
17
+ from ragas.llms import LangchainLLMWrapper
18
+ from ragas.metrics import (
19
+ answer_relevancy,
20
+ context_precision,
21
+ context_recall,
22
+ faithfulness,
23
+ )
24
+
25
+ from app.core.config import settings
26
+ from app.db.sqlite import init_db
27
+ from app.rag.embeddings import get_embedding_model
28
+ from app.rag.graph import ClaimsRAGGraph
29
+ from app.rag.ingestion import DocumentIngestionService
30
+ from app.rag.qdrant_store import QdrantVectorStore
31
+
32
+
33
+ def load_jsonl(path: Path) -> list[dict[str, Any]]:
34
+ rows = []
35
+ for line in path.read_text(encoding="utf-8").splitlines():
36
+ if line.strip():
37
+ rows.append(json.loads(line))
38
+ return rows
39
+
40
+
41
+ def default_reference(case: dict[str, Any]) -> str:
42
+ if case.get("reference"):
43
+ return str(case["reference"])
44
+ decision = case["expected_decision"]
45
+ return (
46
+ f"Decision: {decision}. The answer should use the retrieved insurance claim "
47
+ "guidance to explain the coverage triage, identify missing evidence, and "
48
+ "recommend the next action without inventing unsupported policy terms."
49
+ )
50
+
51
+
52
+ def build_eval_rows(dataset_path: Path, user_id: str, limit: int | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
53
+ init_db()
54
+ QdrantVectorStore().ensure_collections()
55
+ DocumentIngestionService().ingest_pdf_directory()
56
+ graph = ClaimsRAGGraph()
57
+
58
+ cases = load_jsonl(dataset_path)
59
+ if limit:
60
+ cases = cases[:limit]
61
+
62
+ ragas_rows = []
63
+ run_rows = []
64
+ for case in cases:
65
+ started = time.perf_counter()
66
+ state = graph.run(case["query"], user_id=user_id, use_cache=False)
67
+ latency_ms = round((time.perf_counter() - started) * 1000, 2)
68
+ sources = state.get("reranked_sources") or state.get("sources", [])
69
+ contexts = [str(source.get("text", "")) for source in sources if source.get("text")]
70
+
71
+ ragas_rows.append(
72
+ {
73
+ "user_input": case["query"],
74
+ "response": state.get("answer", ""),
75
+ "retrieved_contexts": contexts,
76
+ "reference": default_reference(case),
77
+ }
78
+ )
79
+ run_rows.append(
80
+ {
81
+ "id": case["id"],
82
+ "expected_decision": case["expected_decision"],
83
+ "sources": len(contexts),
84
+ "latency_ms": latency_ms,
85
+ }
86
+ )
87
+ return ragas_rows, run_rows
88
+
89
+
90
+ async def run_ragas(dataset_path: Path, user_id: str, limit: int | None) -> dict[str, Any]:
91
+ if not settings.groq_api_key:
92
+ raise RuntimeError("GROQ_API_KEY is required for RAGAS LLM-judge metrics.")
93
+
94
+ ragas_rows, run_rows = build_eval_rows(dataset_path, user_id, limit)
95
+ dataset = Dataset.from_list(ragas_rows)
96
+
97
+ judge_llm = ChatGroq(
98
+ model=settings.groq_model,
99
+ temperature=0,
100
+ max_retries=2,
101
+ api_key=settings.groq_api_key,
102
+ )
103
+ ragas_llm = LangchainLLMWrapper(judge_llm)
104
+ ragas_embeddings = LangchainEmbeddingsWrapper(get_embedding_model().model)
105
+
106
+ answer_relevancy.strictness = 1
107
+ metrics = [faithfulness, answer_relevancy, context_precision, context_recall]
108
+
109
+ result = evaluate(
110
+ dataset=dataset,
111
+ metrics=metrics,
112
+ llm=ragas_llm,
113
+ embeddings=ragas_embeddings,
114
+ run_config=RunConfig(timeout=180, max_workers=2, max_retries=2),
115
+ )
116
+
117
+ scores = result.to_pandas().to_dict(orient="records")
118
+ rows = []
119
+ for run_row, score_row in zip(run_rows, scores, strict=False):
120
+ rows.append({**run_row, "ragas": score_row})
121
+
122
+ metric_names = ["faithfulness", "answer_relevancy", "context_precision", "context_recall"]
123
+ summary = {
124
+ "total": len(rows),
125
+ "metrics": {},
126
+ "results": rows,
127
+ }
128
+ for metric in metric_names:
129
+ values = [
130
+ float(row["ragas"][metric])
131
+ for row in rows
132
+ if row["ragas"].get(metric) is not None and str(row["ragas"][metric]).lower() != "nan"
133
+ ]
134
+ summary["metrics"][metric] = round(sum(values) / len(values), 3) if values else None
135
+ return summary
136
+
137
+
138
+ def main() -> None:
139
+ parser = argparse.ArgumentParser()
140
+ parser.add_argument("--dataset", default="data/eval/golden_claim_scenarios.jsonl")
141
+ parser.add_argument("--user-id", default="ragas_eval_user")
142
+ parser.add_argument("--limit", type=int, default=None)
143
+ parser.add_argument("--output", default=None)
144
+ args = parser.parse_args()
145
+
146
+ summary = asyncio.run(run_ragas(Path(args.dataset), args.user_id, args.limit))
147
+ text = json.dumps(summary, indent=2)
148
+ print(text)
149
+ if args.output:
150
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()