Spaces:
Sleeping
Sleeping
added
Browse files- .dockerignore +12 -0
- .env.example +30 -0
- .gitignore +12 -0
- Dockerfile +33 -0
- LICENSE +21 -0
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/routes_health.py +8 -0
- app/api/routes_ingest.py +46 -0
- app/api/routes_query.py +72 -0
- app/api/routes_review.py +86 -0
- app/core/__init__.py +1 -0
- app/core/config.py +53 -0
- app/core/logging.py +8 -0
- app/db/__init__.py +1 -0
- app/db/sqlite.py +162 -0
- app/guardrails/__init__.py +1 -0
- app/guardrails/pii.py +82 -0
- app/guardrails/prompt_injection.py +25 -0
- app/main.py +54 -0
- app/rag/__init__.py +1 -0
- app/rag/bm25.py +75 -0
- app/rag/document_cache.py +97 -0
- app/rag/embeddings.py +26 -0
- app/rag/graph.py +497 -0
- app/rag/hybrid_retriever.py +29 -0
- app/rag/ingestion.py +143 -0
- app/rag/qdrant_store.py +110 -0
- app/rag/reranker.py +82 -0
- app/rag/rrf.py +20 -0
- app/rag/self_rag.py +171 -0
- app/rag/semantic_cache.py +53 -0
- app/rag/state.py +47 -0
- app/rag/text.py +56 -0
- app/services/__init__.py +1 -0
- app/services/groq_llm.py +73 -0
- app/services/memory.py +159 -0
- data/eval/golden_claim_scenarios.jsonl +14 -0
- data/sample_insurance_claim_guide.pdf +0 -0
- docker-compose.yml +38 -0
- frontend/assets/app.js +83 -0
- frontend/assets/styles.css +224 -0
- frontend/index.html +50 -0
- requirements-docker.txt +22 -0
- scripts/generate_sample_claim_pdf.py +315 -0
- scripts/run_eval.py +108 -0
- scripts/run_ragas_eval.py +154 -0
.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("&", "&")
|
| 71 |
+
.replaceAll("<", "<")
|
| 72 |
+
.replaceAll(">", ">")
|
| 73 |
+
.replaceAll('"', """)
|
| 74 |
+
.replaceAll("'", "'");
|
| 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()
|