Commit Β·
ae11f2d
1
Parent(s): 6eea31b
Debajyoti commit
Browse files- config.py +2 -2
- document_manager.py +5 -14
- models.py +12 -9
- pdf_loader.py +17 -39
- query_service.py +29 -18
- requirements.txt +1 -0
- retriever.py +48 -40
- test.py +21 -13
- workflow.py +49 -97
config.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import os
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
|
| 6 |
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
| 7 |
API_AUTH_TOKEN = os.getenv("API_AUTH_TOKEN")
|
|
|
|
| 1 |
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
|
| 4 |
+
load_dotenv()
|
| 5 |
|
| 6 |
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
| 7 |
API_AUTH_TOKEN = os.getenv("API_AUTH_TOKEN")
|
document_manager.py
CHANGED
|
@@ -9,38 +9,29 @@ class DocumentManager:
|
|
| 9 |
self.document_url = document_url
|
| 10 |
self.DIR = "/tmp/doc_cache"
|
| 11 |
os.makedirs(self.DIR, exist_ok=True)
|
| 12 |
-
|
| 13 |
-
# Try to fetch a cached path; if none, download and cache it
|
| 14 |
cached_path = self._get_cached_path()
|
| 15 |
-
if cached_path:
|
| 16 |
-
# Cache hit
|
| 17 |
self.file_path = cached_path
|
| 18 |
self.filename = os.path.basename(cached_path)
|
|
|
|
| 19 |
else:
|
| 20 |
-
|
| 21 |
self.file_path, self.filename = self._download_and_cache()
|
| 22 |
|
| 23 |
def _get_cached_path(self) -> str:
|
| 24 |
-
"""Return the cached file path for this URL, or '' if not present."""
|
| 25 |
with shelve.open(os.path.join(self.DIR, 'cache')) as cache:
|
| 26 |
return cache.get(self.document_url, '')
|
| 27 |
|
| 28 |
def _download_and_cache(self) -> Tuple[str, str]:
|
| 29 |
-
"""Download the document, save it, cache the path, and return (path, name)."""
|
| 30 |
-
print("Downloading file...")
|
| 31 |
response = requests.get(self.document_url, timeout=60)
|
| 32 |
response.raise_for_status()
|
| 33 |
-
|
| 34 |
filename = f"{uuid.uuid4()}.pdf"
|
| 35 |
file_path = os.path.join(self.DIR, filename)
|
| 36 |
-
|
| 37 |
with open(file_path, "wb") as f:
|
| 38 |
f.write(response.content)
|
| 39 |
-
print('File downloaded.')
|
| 40 |
-
# Store in cache
|
| 41 |
with shelve.open(os.path.join(self.DIR, 'cache')) as cache:
|
| 42 |
cache[self.document_url] = file_path
|
| 43 |
-
|
| 44 |
return file_path, filename
|
| 45 |
|
| 46 |
def get_filepath(self) -> str:
|
|
@@ -51,4 +42,4 @@ class DocumentManager:
|
|
| 51 |
|
| 52 |
def cleanup(self):
|
| 53 |
if getattr(self, 'file_path', None) and os.path.exists(self.file_path):
|
| 54 |
-
os.remove(self.file_path)
|
|
|
|
| 9 |
self.document_url = document_url
|
| 10 |
self.DIR = "/tmp/doc_cache"
|
| 11 |
os.makedirs(self.DIR, exist_ok=True)
|
|
|
|
|
|
|
| 12 |
cached_path = self._get_cached_path()
|
| 13 |
+
if cached_path and os.path.exists(cached_path):
|
|
|
|
| 14 |
self.file_path = cached_path
|
| 15 |
self.filename = os.path.basename(cached_path)
|
| 16 |
+
print(f"Loaded '{self.filename}' from cache.")
|
| 17 |
else:
|
| 18 |
+
print("Document not in cache. Downloading...")
|
| 19 |
self.file_path, self.filename = self._download_and_cache()
|
| 20 |
|
| 21 |
def _get_cached_path(self) -> str:
|
|
|
|
| 22 |
with shelve.open(os.path.join(self.DIR, 'cache')) as cache:
|
| 23 |
return cache.get(self.document_url, '')
|
| 24 |
|
| 25 |
def _download_and_cache(self) -> Tuple[str, str]:
|
|
|
|
|
|
|
| 26 |
response = requests.get(self.document_url, timeout=60)
|
| 27 |
response.raise_for_status()
|
|
|
|
| 28 |
filename = f"{uuid.uuid4()}.pdf"
|
| 29 |
file_path = os.path.join(self.DIR, filename)
|
|
|
|
| 30 |
with open(file_path, "wb") as f:
|
| 31 |
f.write(response.content)
|
|
|
|
|
|
|
| 32 |
with shelve.open(os.path.join(self.DIR, 'cache')) as cache:
|
| 33 |
cache[self.document_url] = file_path
|
| 34 |
+
print("Download complete and cached.")
|
| 35 |
return file_path, filename
|
| 36 |
|
| 37 |
def get_filepath(self) -> str:
|
|
|
|
| 42 |
|
| 43 |
def cleanup(self):
|
| 44 |
if getattr(self, 'file_path', None) and os.path.exists(self.file_path):
|
| 45 |
+
os.remove(self.file_path)
|
models.py
CHANGED
|
@@ -1,21 +1,24 @@
|
|
| 1 |
from pydantic import BaseModel, HttpUrl, Field
|
| 2 |
from typing import List
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
class Question(BaseModel):
|
| 5 |
question: str
|
| 6 |
|
| 7 |
class FinalAnswer(BaseModel):
|
| 8 |
-
answer: str
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class GeneratedQueriesForEachQuestion(BaseModel):
|
| 11 |
queries: List[str] = Field(description="A list of 3 distinct, self-contained search queries based on the original question.")
|
| 12 |
|
| 13 |
class GeneratedQueries(BaseModel):
|
| 14 |
-
lst: List[GeneratedQueriesForEachQuestion]
|
| 15 |
-
|
| 16 |
-
class QueryResponse(BaseModel):
|
| 17 |
-
answers: List[str]
|
| 18 |
-
|
| 19 |
-
class QueryRequest(BaseModel):
|
| 20 |
-
documents: HttpUrl
|
| 21 |
-
questions: List[str]
|
|
|
|
| 1 |
from pydantic import BaseModel, HttpUrl, Field
|
| 2 |
from typing import List
|
| 3 |
|
| 4 |
+
class QueryRequest(BaseModel):
|
| 5 |
+
documents: HttpUrl
|
| 6 |
+
questions: List[str]
|
| 7 |
+
|
| 8 |
+
class QueryResponse(BaseModel):
|
| 9 |
+
answers: List[str]
|
| 10 |
+
|
| 11 |
class Question(BaseModel):
|
| 12 |
question: str
|
| 13 |
|
| 14 |
class FinalAnswer(BaseModel):
|
| 15 |
+
answer: str = Field(description="The complete and detailed answer to the user's question, based strictly on the provided context.")
|
| 16 |
+
|
| 17 |
+
class FinalAnswerList(BaseModel):
|
| 18 |
+
answers: List[FinalAnswer] = Field(description="A list of final answers for each corresponding input question.")
|
| 19 |
|
| 20 |
class GeneratedQueriesForEachQuestion(BaseModel):
|
| 21 |
queries: List[str] = Field(description="A list of 3 distinct, self-contained search queries based on the original question.")
|
| 22 |
|
| 23 |
class GeneratedQueries(BaseModel):
|
| 24 |
+
lst: List[GeneratedQueriesForEachQuestion]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pdf_loader.py
CHANGED
|
@@ -1,49 +1,27 @@
|
|
| 1 |
import os
|
| 2 |
-
import
|
| 3 |
-
import fitz
|
| 4 |
from langchain_core.documents import Document
|
| 5 |
from typing import List
|
| 6 |
|
| 7 |
class PDFLoader:
|
| 8 |
def __init__(self, file_path: str):
|
|
|
|
|
|
|
| 9 |
self.file_path = file_path
|
| 10 |
|
| 11 |
-
def
|
| 12 |
-
return bool(re.search(r"(\s{2,}|\t)", line_text)) and len(line_text.strip()) > 10
|
| 13 |
-
|
| 14 |
-
def load(self) -> list[Document]:
|
| 15 |
documents = []
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
full_text = "\n".join(text_lines).strip()
|
| 31 |
-
table_text = "\n".join(table_lines).strip()
|
| 32 |
-
|
| 33 |
-
combined_text = ""
|
| 34 |
-
if full_text:
|
| 35 |
-
combined_text += "### Text Content ###\n" + full_text + "\n"
|
| 36 |
-
if table_text:
|
| 37 |
-
combined_text += "\n### Table Content ###\n" + table_text + "\n"
|
| 38 |
-
|
| 39 |
-
if combined_text:
|
| 40 |
-
documents.append(
|
| 41 |
-
Document(
|
| 42 |
-
page_content=combined_text.strip(),
|
| 43 |
-
metadata={
|
| 44 |
-
"page": page_number + 1,
|
| 45 |
-
"source_file": os.path.basename(self.file_path),
|
| 46 |
-
}
|
| 47 |
-
)
|
| 48 |
-
)
|
| 49 |
return documents
|
|
|
|
| 1 |
import os
|
| 2 |
+
import pymupdf4llm
|
|
|
|
| 3 |
from langchain_core.documents import Document
|
| 4 |
from typing import List
|
| 5 |
|
| 6 |
class PDFLoader:
|
| 7 |
def __init__(self, file_path: str):
|
| 8 |
+
if not os.path.exists(file_path):
|
| 9 |
+
raise FileNotFoundError(f"The file {file_path} does not exist.")
|
| 10 |
self.file_path = file_path
|
| 11 |
|
| 12 |
+
def load(self) -> List[Document]:
|
|
|
|
|
|
|
|
|
|
| 13 |
documents = []
|
| 14 |
+
page_markdowns = pymupdf4llm.to_markdown(self.file_path, page_chunks=True)
|
| 15 |
+
|
| 16 |
+
for i, md_output in enumerate(page_markdowns):
|
| 17 |
+
content = str(md_output)
|
| 18 |
+
doc = Document(
|
| 19 |
+
page_content=content,
|
| 20 |
+
metadata={
|
| 21 |
+
"page": i + 1,
|
| 22 |
+
"source": self.file_path,
|
| 23 |
+
"source_file": os.path.basename(self.file_path),
|
| 24 |
+
}
|
| 25 |
+
)
|
| 26 |
+
documents.append(doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
return documents
|
query_service.py
CHANGED
|
@@ -1,32 +1,43 @@
|
|
| 1 |
from typing import List
|
| 2 |
-
from models import Question,FinalAnswer
|
| 3 |
from document_manager import DocumentManager
|
| 4 |
-
from retriever import
|
| 5 |
from workflow import RAGWorkflow
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
class QueryService:
|
| 8 |
-
"""
|
| 9 |
-
A service class to orchestrate the RAG process:
|
| 10 |
-
- Downloads documents
|
| 11 |
-
- Manages a cache of processed vector stores
|
| 12 |
-
- Generates responses based on retrieved information and questions.
|
| 13 |
-
"""
|
| 14 |
def __init__(self):
|
| 15 |
-
self.
|
|
|
|
| 16 |
|
| 17 |
def process_queries(
|
| 18 |
self,
|
| 19 |
document_url: str,
|
| 20 |
questions: List[Question]
|
| 21 |
) -> List[FinalAnswer]:
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
retriever = VectorStoreProvider(document_manager).retriever
|
| 28 |
-
print("retriever created....\ncalling llm")
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import List
|
| 2 |
+
from models import Question, FinalAnswer
|
| 3 |
from document_manager import DocumentManager
|
| 4 |
+
from retriever import VectorStoreManager
|
| 5 |
from workflow import RAGWorkflow
|
| 6 |
+
from config import GOOGLE_API_KEY
|
| 7 |
+
from rich import print as rprint
|
| 8 |
+
from rich.panel import Panel
|
| 9 |
|
| 10 |
class QueryService:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def __init__(self):
|
| 12 |
+
self.workflow = RAGWorkflow()
|
| 13 |
+
rprint(Panel("[bold green]QueryService Initialized[/bold green]", subtitle="Ready to process requests.", border_style="green"))
|
| 14 |
|
| 15 |
def process_queries(
|
| 16 |
self,
|
| 17 |
document_url: str,
|
| 18 |
questions: List[Question]
|
| 19 |
) -> List[FinalAnswer]:
|
| 20 |
+
try:
|
| 21 |
+
rprint(Panel(f"Fetching document from URL:\n[cyan]{document_url}[/cyan]", title="[yellow]Step 1: Document Manager[/yellow]", border_style="yellow"))
|
| 22 |
+
doc_manager = DocumentManager(document_url)
|
| 23 |
+
file_path = doc_manager.get_filepath()
|
| 24 |
+
rprint(f"[green]Document located at:[/] {file_path}")
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
rprint(Panel("Initializing vector store and retriever...\nThis may involve creating new embeddings if the document is new.", title="[yellow]Step 2: Vector Store Manager[/yellow]", border_style="yellow"))
|
| 27 |
+
store_manager = VectorStoreManager(
|
| 28 |
+
file_path=file_path,
|
| 29 |
+
embedding_type="google",
|
| 30 |
+
google_api_key=GOOGLE_API_KEY
|
| 31 |
+
)
|
| 32 |
+
retriever = store_manager.retriever
|
| 33 |
+
rprint(f"[green]Retriever created successfully. Indexed [bold]{len(store_manager.documents)}[/bold] chunks.[/green]")
|
| 34 |
|
| 35 |
+
rprint(Panel(f"Invoking RAG workflow for {len(questions)} questions...", title="[yellow]Step 3: RAG Workflow[/yellow]", border_style="yellow"))
|
| 36 |
+
results = self.workflow.invoke(questions, retriever)
|
| 37 |
+
rprint("[bold green]Workflow finished successfully.[/bold green]")
|
| 38 |
+
|
| 39 |
+
return results
|
| 40 |
+
|
| 41 |
+
except Exception as e:
|
| 42 |
+
rprint(Panel(f"[bold red]An error occurred during query processing:[/] {e}", title="[red]FATAL ERROR[/red]"))
|
| 43 |
+
raise
|
requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ pymupdf
|
|
| 10 |
python-dotenv
|
| 11 |
requests
|
| 12 |
rich
|
|
|
|
|
|
| 10 |
python-dotenv
|
| 11 |
requests
|
| 12 |
rich
|
| 13 |
+
pymupdf4llm
|
retriever.py
CHANGED
|
@@ -1,49 +1,57 @@
|
|
| 1 |
-
from
|
| 2 |
-
from
|
| 3 |
-
from
|
| 4 |
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 5 |
-
from langchain_community.
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
def __init__(self, manager: DocumentManager):
|
| 11 |
-
self.manager = manager
|
| 12 |
-
self.retriever = self._create_retriever()
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
raw_documents = loader.load()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
persist_directory="/tmp/vector_db",
|
| 31 |
)
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
existing = db.get(
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
if not existing:
|
| 41 |
-
print("Creating new embeddings.")
|
| 42 |
-
db.add_documents(split_docs)
|
| 43 |
else:
|
| 44 |
-
print("Embeddings already exist")
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
)
|
| 49 |
-
|
|
|
|
|
|
| 1 |
+
from typing import List, Literal
|
| 2 |
+
from langchain_core.documents import Document
|
| 3 |
+
from langchain_community.vectorstores import Chroma
|
| 4 |
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 5 |
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
| 6 |
+
from langchain_google_genai import GoogleGenerativeAIEmbeddings
|
| 7 |
+
from langchain_community.retrievers import BM25Retriever
|
| 8 |
+
from langchain.retrievers import EnsembleRetriever
|
| 9 |
+
from pdf_loader import PDFLoader
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
class VectorStoreManager:
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
file_path: str,
|
| 15 |
+
persist_directory: str = "/tmp/vector_db_hybrid",
|
| 16 |
+
collection_name: str = "pdf_docs_hybrid",
|
| 17 |
+
embedding_type: Literal["huggingface", "google"] = "huggingface",
|
| 18 |
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 19 |
+
google_api_key: str = None,
|
| 20 |
+
):
|
| 21 |
+
self.file_path = file_path
|
| 22 |
+
loader = PDFLoader(file_path)
|
| 23 |
raw_documents = loader.load()
|
| 24 |
+
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1200, chunk_overlap=200)
|
| 25 |
+
self.documents = text_splitter.split_documents(raw_documents)
|
| 26 |
|
| 27 |
+
if embedding_type == "huggingface":
|
| 28 |
+
self.embedding_model = HuggingFaceEmbeddings(model_name=model_name)
|
| 29 |
+
elif embedding_type == "google":
|
| 30 |
+
if not google_api_key:
|
| 31 |
+
raise ValueError("Google API key is required for Google embeddings.")
|
| 32 |
+
self.embedding_model = GoogleGenerativeAIEmbeddings(model="models/embedding-001", google_api_key=google_api_key)
|
| 33 |
+
else:
|
| 34 |
+
raise ValueError("embedding_type must be 'huggingface' or 'google'")
|
| 35 |
|
| 36 |
+
self.db = Chroma(
|
| 37 |
+
collection_name=collection_name,
|
| 38 |
+
embedding_function=self.embedding_model,
|
| 39 |
+
persist_directory=persist_directory,
|
|
|
|
| 40 |
)
|
| 41 |
+
self._add_documents_if_new()
|
| 42 |
+
self.retriever = self._create_hybrid_retriever()
|
| 43 |
|
| 44 |
+
def _add_documents_if_new(self):
|
| 45 |
+
existing = self.db.get(where={"source": self.file_path}, include=[])
|
| 46 |
+
if not existing["ids"]:
|
| 47 |
+
print(f"No existing embeddings for {self.file_path}. Adding new ones.")
|
| 48 |
+
self.db.add_documents(self.documents)
|
| 49 |
+
self.db.persist()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
else:
|
| 51 |
+
print(f"Embeddings for {self.file_path} already exist.")
|
| 52 |
|
| 53 |
+
def _create_hybrid_retriever(self, k: int = 5) -> EnsembleRetriever:
|
| 54 |
+
dense_retriever = self.db.as_retriever(search_kwargs={"k": k, "filter": {"source": self.file_path}})
|
| 55 |
+
bm25_retriever = BM25Retriever.from_documents(self.documents)
|
| 56 |
+
bm25_retriever.k = k
|
| 57 |
+
return EnsembleRetriever(retrievers=[dense_retriever, bm25_retriever], weights=[0.5, 0.5])
|
test.py
CHANGED
|
@@ -5,38 +5,46 @@ from query_service import QueryService
|
|
| 5 |
from typing import List
|
| 6 |
|
| 7 |
def run_test():
|
| 8 |
-
TEST_DOCUMENT_URL = "https://
|
|
|
|
| 9 |
questions = [
|
| 10 |
-
"What is
|
| 11 |
-
"What
|
| 12 |
-
"
|
| 13 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
]
|
|
|
|
| 15 |
TEST_QUESTIONS = [Question(question=question) for question in questions]
|
|
|
|
| 16 |
rprint(Panel(
|
| 17 |
f"Document URL: [blue]{TEST_DOCUMENT_URL}[/blue]\n"
|
| 18 |
f"Questions: [yellow]{len(TEST_QUESTIONS)}[/yellow]",
|
| 19 |
-
title="[bold green]Starting
|
| 20 |
border_style="green"
|
| 21 |
))
|
| 22 |
|
| 23 |
query_service = QueryService()
|
| 24 |
|
| 25 |
-
results:List[FinalAnswer] = query_service.process_queries(
|
| 26 |
document_url=TEST_DOCUMENT_URL,
|
| 27 |
questions=TEST_QUESTIONS
|
| 28 |
)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
for i, (response) in enumerate(results):
|
| 32 |
|
|
|
|
| 33 |
question_panel = Panel(
|
| 34 |
-
f"[bold]Answer:[/
|
| 35 |
-
title=f"[bold magenta]Result for Question #{i+1}[/bold magenta]: {TEST_QUESTIONS[i]}",
|
| 36 |
border_style="magenta",
|
| 37 |
expand=True
|
| 38 |
)
|
| 39 |
-
|
| 40 |
rprint(question_panel)
|
| 41 |
|
| 42 |
if __name__ == "__main__":
|
|
|
|
| 5 |
from typing import List
|
| 6 |
|
| 7 |
def run_test():
|
| 8 |
+
TEST_DOCUMENT_URL = "https://hackrx.blob.core.windows.net/assets/policy.pdf?sv=2023-01-03&st=2025-07-04T09%3A11%3A24Z&se=2027-07-05T09%3A11%3A00Z&sr=b&sp=r&sig=N4a9OU0w0QXO6AOIBiu4bpl7AXvEZogeT%2FjUHNO7HzQ%3D"
|
| 9 |
+
|
| 10 |
questions = [
|
| 11 |
+
"What is the grace period for premium payment under the National Parivar Mediclaim Plus Policy?",
|
| 12 |
+
"What is the waiting period for pre-existing diseases (PED) to be covered?",
|
| 13 |
+
"Does this policy cover maternity expenses, and what are the conditions?",
|
| 14 |
+
"What is the waiting period for cataract surgery?",
|
| 15 |
+
"Are the medical expenses for an organ donor covered under this policy?",
|
| 16 |
+
"What is the No Claim Discount (NCD) offered in this policy?",
|
| 17 |
+
"Is there a benefit for preventive health check-ups?",
|
| 18 |
+
"How does the policy define a 'Hospital'?",
|
| 19 |
+
"What is the extent of coverage for AYUSH treatments?",
|
| 20 |
+
"Are there any sub-limits on room rent and ICU charges for Plan A?"
|
| 21 |
]
|
| 22 |
+
|
| 23 |
TEST_QUESTIONS = [Question(question=question) for question in questions]
|
| 24 |
+
|
| 25 |
rprint(Panel(
|
| 26 |
f"Document URL: [blue]{TEST_DOCUMENT_URL}[/blue]\n"
|
| 27 |
f"Questions: [yellow]{len(TEST_QUESTIONS)}[/yellow]",
|
| 28 |
+
title="[bold green]Starting Insurance Policy Test[/bold green]",
|
| 29 |
border_style="green"
|
| 30 |
))
|
| 31 |
|
| 32 |
query_service = QueryService()
|
| 33 |
|
| 34 |
+
results: List[FinalAnswer] = query_service.process_queries(
|
| 35 |
document_url=TEST_DOCUMENT_URL,
|
| 36 |
questions=TEST_QUESTIONS
|
| 37 |
)
|
| 38 |
+
|
| 39 |
+
rprint(Panel("[bold green]Processing Complete. Displaying Final Results...[/bold green]"))
|
|
|
|
| 40 |
|
| 41 |
+
for i, response in enumerate(results):
|
| 42 |
question_panel = Panel(
|
| 43 |
+
f"[bold]Answer:[/] {response.answer}",
|
| 44 |
+
title=f"[bold magenta]Result for Question #{i+1}[/bold magenta]: {TEST_QUESTIONS[i].question}",
|
| 45 |
border_style="magenta",
|
| 46 |
expand=True
|
| 47 |
)
|
|
|
|
| 48 |
rprint(question_panel)
|
| 49 |
|
| 50 |
if __name__ == "__main__":
|
workflow.py
CHANGED
|
@@ -3,122 +3,75 @@ from langchain_core.documents import Document
|
|
| 3 |
from langgraph.graph import StateGraph, END
|
| 4 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 5 |
from langchain_core.prompts import ChatPromptTemplate
|
| 6 |
-
from
|
| 7 |
-
from models import
|
| 8 |
-
from config import ANSWER_LLM_MODEL,QUERY_LLM_MODEL,GOOGLE_API_KEY
|
| 9 |
|
| 10 |
class GraphState(TypedDict):
|
| 11 |
original_questions: List[Question]
|
| 12 |
decomposed_questions: GeneratedQueries
|
| 13 |
-
retriever:
|
| 14 |
documents: List[List[Document]]
|
| 15 |
answers: List[FinalAnswer]
|
| 16 |
|
| 17 |
class RAGWorkflow:
|
| 18 |
def __init__(self):
|
| 19 |
-
self.generation_llm = ChatGoogleGenerativeAI(model=ANSWER_LLM_MODEL, api_key=GOOGLE_API_KEY, temperature=0)
|
| 20 |
-
self.decomposition_llm = ChatGoogleGenerativeAI(model=QUERY_LLM_MODEL, api_key=GOOGLE_API_KEY, temperature=0)
|
| 21 |
self.graph = self._build_graph()
|
| 22 |
|
| 23 |
def _query_decomposition_node(self, state: GraphState):
|
| 24 |
prompt = ChatPromptTemplate.from_template(
|
| 25 |
-
"""You are
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
**Instructions for Query Generation:**
|
| 30 |
-
1. **Specificity is Key:** Use precise terminology found in insurance policies.
|
| 31 |
-
2. **Target All Facets:** Generate queries that cover different aspects of the question, especially:
|
| 32 |
-
- The core topic (e.g., "maternity expenses").
|
| 33 |
-
- Associated **conditions and eligibility criteria** (e.g., "maternity coverage waiting period").
|
| 34 |
-
- Specific **limits, sub-limits, or exclusions** (e.g., "monetary limit for childbirth expenses").
|
| 35 |
-
3. **Self-Contained:** Each query must make sense on its own without relying on the original question's context.
|
| 36 |
-
|
| 37 |
-
Return a Pydantic `GeneratedQueries` object with field `lst`, a list of length N. Each `lst[i]` is a `GeneratedQueriesForEachQuestion` containing exactly 3 queries for question i.
|
| 38 |
-
|
| 39 |
-
USER QUESTIONS:
|
| 40 |
-
{questions}"""
|
| 41 |
)
|
| 42 |
-
|
| 43 |
questions_str = "\n".join(f"{i+1}. {q.question}" for i, q in enumerate(state["original_questions"]))
|
| 44 |
-
|
| 45 |
structured_llm = self.decomposition_llm.with_structured_output(GeneratedQueries)
|
| 46 |
-
|
| 47 |
-
generated_lists: GeneratedQueries =
|
| 48 |
-
|
| 49 |
-
if not generated_lists or len(generated_lists.lst) != len(state["original_questions"]):
|
| 50 |
-
lst = []
|
| 51 |
-
for q in state["original_questions"]:
|
| 52 |
-
lst.append(GeneratedQueriesForEachQuestion(queries=[q.question]))
|
| 53 |
-
|
| 54 |
-
default = GeneratedQueries(lst=lst)
|
| 55 |
-
return {"decomposed_questions": default}
|
| 56 |
-
|
| 57 |
-
for i,el in enumerate(generated_lists.lst):
|
| 58 |
el.queries.append(state["original_questions"][i].question)
|
| 59 |
return {"decomposed_questions": generated_lists}
|
| 60 |
|
| 61 |
def _retrieval_node(self, state: GraphState):
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
need to flatten every 4 nested lists together.
|
| 73 |
-
documents: N length nested list od documents.
|
| 74 |
-
"""
|
| 75 |
-
docs_lists = state["retriever"].batch(queries)
|
| 76 |
-
per_q = len(state["decomposed_questions"].lst[0].queries)
|
| 77 |
-
# flatten and dedupe by page content (or metadata)
|
| 78 |
-
documents:List[List[Document]] = []
|
| 79 |
-
for i in range(0,len(docs_lists), per_q):
|
| 80 |
-
single_question_docs = [doc for docs in docs_lists[i:i+per_q] for doc in docs]
|
| 81 |
-
unique = {doc.page_content: doc for doc in single_question_docs}
|
| 82 |
-
documents.append(list(unique.values()))
|
| 83 |
-
|
| 84 |
-
# self.pretty_print_documents_simple(documents)
|
| 85 |
-
|
| 86 |
-
return {"documents": documents}
|
| 87 |
|
| 88 |
def _generation_node(self, state: GraphState):
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
Based on your analysis, provide the complete answer in the required Pydantic `FinalAnswer` object format."""
|
| 113 |
-
)
|
| 114 |
-
structured_llm = self.generation_llm.with_structured_output(FinalAnswer)
|
| 115 |
-
chain = prompt | structured_llm
|
| 116 |
-
batch_inputs = [
|
| 117 |
-
{"context": contexts[i], "question": questions[i]} for i in range(N)
|
| 118 |
-
]
|
| 119 |
-
|
| 120 |
-
final_answers: List[FinalAnswer] = chain.batch(batch_inputs) # type: ignore
|
| 121 |
-
return {"answers": final_answers}
|
| 122 |
|
| 123 |
def _build_graph(self):
|
| 124 |
workflow = StateGraph(GraphState)
|
|
@@ -131,8 +84,7 @@ class RAGWorkflow:
|
|
| 131 |
workflow.add_edge("generate", END)
|
| 132 |
return workflow.compile()
|
| 133 |
|
| 134 |
-
def invoke(self, questions: List[Question], retriever:
|
| 135 |
initial_state = {"original_questions": questions, "retriever": retriever}
|
| 136 |
-
final_state = self.graph.invoke(initial_state)
|
| 137 |
-
|
| 138 |
-
return answer_objects
|
|
|
|
| 3 |
from langgraph.graph import StateGraph, END
|
| 4 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 5 |
from langchain_core.prompts import ChatPromptTemplate
|
| 6 |
+
from langchain.retrievers import EnsembleRetriever
|
| 7 |
+
from models import Question, FinalAnswer, FinalAnswerList, GeneratedQueries
|
| 8 |
+
from config import ANSWER_LLM_MODEL, QUERY_LLM_MODEL, GOOGLE_API_KEY
|
| 9 |
|
| 10 |
class GraphState(TypedDict):
|
| 11 |
original_questions: List[Question]
|
| 12 |
decomposed_questions: GeneratedQueries
|
| 13 |
+
retriever: EnsembleRetriever
|
| 14 |
documents: List[List[Document]]
|
| 15 |
answers: List[FinalAnswer]
|
| 16 |
|
| 17 |
class RAGWorkflow:
|
| 18 |
def __init__(self):
|
| 19 |
+
self.generation_llm = ChatGoogleGenerativeAI(model=ANSWER_LLM_MODEL, api_key=GOOGLE_API_KEY, temperature=0.0)
|
| 20 |
+
self.decomposition_llm = ChatGoogleGenerativeAI(model=QUERY_LLM_MODEL, api_key=GOOGLE_API_KEY, temperature=0.0)
|
| 21 |
self.graph = self._build_graph()
|
| 22 |
|
| 23 |
def _query_decomposition_node(self, state: GraphState):
|
| 24 |
prompt = ChatPromptTemplate.from_template(
|
| 25 |
+
"""π§ You are a hyper-analytical AI specializing in dissecting legal and insurance documents.
|
| 26 |
+
π For each user question, create exactly 3 distinct, self-contained search queries.
|
| 27 |
+
π¦ Return a Pydantic `GeneratedQueries` object.
|
| 28 |
|
| 29 |
+
π USER QUESTIONS:
|
| 30 |
+
{questions}"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
)
|
|
|
|
| 32 |
questions_str = "\n".join(f"{i+1}. {q.question}" for i, q in enumerate(state["original_questions"]))
|
|
|
|
| 33 |
structured_llm = self.decomposition_llm.with_structured_output(GeneratedQueries)
|
| 34 |
+
decomposition_chain = prompt | structured_llm
|
| 35 |
+
generated_lists: GeneratedQueries = decomposition_chain.invoke({"questions": questions_str})
|
| 36 |
+
for i, el in enumerate(generated_lists.lst):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
el.queries.append(state["original_questions"][i].question)
|
| 38 |
return {"decomposed_questions": generated_lists}
|
| 39 |
|
| 40 |
def _retrieval_node(self, state: GraphState):
|
| 41 |
+
all_queries = [q for query_list in state["decomposed_questions"].lst for q in query_list.queries]
|
| 42 |
+
retrieved_docs_lists = state["retriever"].batch(all_queries)
|
| 43 |
+
queries_per_question = len(state["decomposed_questions"].lst[0].queries)
|
| 44 |
+
final_documents: List[List[Document]] = []
|
| 45 |
+
for i in range(len(state["original_questions"])):
|
| 46 |
+
start_index, end_index = i * queries_per_question, (i + 1) * queries_per_question
|
| 47 |
+
single_question_docs = [doc for docs_list in retrieved_docs_lists[start_index:end_index] for doc in docs_list]
|
| 48 |
+
unique_docs = {doc.page_content: doc for doc in single_question_docs}
|
| 49 |
+
final_documents.append(list(unique_docs.values()))
|
| 50 |
+
return {"documents": final_documents}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
def _generation_node(self, state: GraphState):
|
| 53 |
+
prompt_template = """π You are an AI-powered insurance claims adjudicator. Answer questions with extreme precision, based strictly on the provided context.
|
| 54 |
+
|
| 55 |
+
π RULES:
|
| 56 |
+
β
1. Derive answers *only* from the provided text.
|
| 57 |
+
β
2. Quote specific details: monetary limits, waiting periods, percentages.
|
| 58 |
+
π« 3. If the context is insufficient, state: "The provided context does not contain sufficient information to answer this question."
|
| 59 |
+
π¦ 4. Respond with a Pydantic `FinalAnswerList` object.
|
| 60 |
+
|
| 61 |
+
π§Ύ Analyze the following:
|
| 62 |
+
{question_context_pairs}
|
| 63 |
+
"""
|
| 64 |
+
question_context_pairs = []
|
| 65 |
+
for i, question in enumerate(state["original_questions"]):
|
| 66 |
+
context_str = "\n\n---\n\n".join([doc.page_content for doc in state["documents"][i]])
|
| 67 |
+
pair = f"π QUESTION {i+1}:\n{question.question}\n\nπ CONTEXT FOR QUESTION {i+1}:\n{context_str}"
|
| 68 |
+
question_context_pairs.append(pair)
|
| 69 |
+
combined_input = "\n\n====================\n\n".join(question_context_pairs)
|
| 70 |
+
prompt = ChatPromptTemplate.from_template(prompt_template)
|
| 71 |
+
structured_llm = self.generation_llm.with_structured_output(FinalAnswerList)
|
| 72 |
+
generation_chain = prompt | structured_llm
|
| 73 |
+
final_answers_list: FinalAnswerList = generation_chain.invoke({"question_context_pairs": combined_input})
|
| 74 |
+
return {"answers": final_answers_list.answers}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
def _build_graph(self):
|
| 77 |
workflow = StateGraph(GraphState)
|
|
|
|
| 84 |
workflow.add_edge("generate", END)
|
| 85 |
return workflow.compile()
|
| 86 |
|
| 87 |
+
def invoke(self, questions: List[Question], retriever: EnsembleRetriever) -> List[FinalAnswer]:
|
| 88 |
initial_state = {"original_questions": questions, "retriever": retriever}
|
| 89 |
+
final_state = self.graph.invoke(initial_state)
|
| 90 |
+
return final_state.get("answers", [])
|
|
|