Debajyoti2004 commited on
Commit
ae11f2d
Β·
1 Parent(s): 6eea31b

Debajyoti commit

Browse files
Files changed (9) hide show
  1. config.py +2 -2
  2. document_manager.py +5 -14
  3. models.py +12 -9
  4. pdf_loader.py +17 -39
  5. query_service.py +29 -18
  6. requirements.txt +1 -0
  7. retriever.py +48 -40
  8. test.py +21 -13
  9. workflow.py +49 -97
config.py CHANGED
@@ -1,7 +1,7 @@
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")
 
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
- # Cache miss
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] = Field(description="This is a list consisting of another set of nested lists which contain the generated queries for each question.")
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 re
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 _is_table_line(self, line_text: str) -> bool:
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
- with fitz.open(self.file_path) as doc:
17
- for page_number, page in enumerate(doc):
18
- page_dict = page.get_text("dict")
19
- blocks = page_dict.get("blocks", [])
20
- text_lines, table_lines = [], []
21
- for block in blocks:
22
- if block["type"] == 0:
23
- for line in block.get("lines", []):
24
- line_text = " ".join([span["text"] for span in line.get("spans", [])]).strip()
25
- if self._is_table_line(line_text):
26
- table_lines.append(line_text)
27
- else:
28
- text_lines.append(line_text)
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 VectorStoreProvider
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.llm = RAGWorkflow()
 
16
 
17
  def process_queries(
18
  self,
19
  document_url: str,
20
  questions: List[Question]
21
  ) -> List[FinalAnswer]:
22
- """
23
- Processes a list of questions against a document URL.
24
- """
25
- print("Processing new document and building vector store...")
26
- document_manager = DocumentManager(document_url)
27
- retriever = VectorStoreProvider(document_manager).retriever
28
- print("retriever created....\ncalling llm")
29
 
30
- results = self.llm.invoke(questions,retriever)
 
 
 
 
 
 
 
31
 
32
- return results
 
 
 
 
 
 
 
 
 
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 langchain_chroma import Chroma
2
- from langchain_google_genai import GoogleGenerativeAIEmbeddings
3
- from langchain.vectorstores.base import VectorStoreRetriever
4
  from langchain_text_splitters import RecursiveCharacterTextSplitter
5
- from langchain_community.document_loaders import PyMuPDFLoader
6
- from document_manager import DocumentManager
7
- from config import EMBEDDING_MODEL
8
-
9
- class VectorStoreProvider:
10
- def __init__(self, manager: DocumentManager):
11
- self.manager = manager
12
- self.retriever = self._create_retriever()
13
 
14
- def _create_retriever(self) -> VectorStoreRetriever:
15
- file_path = self.manager.get_filepath()
16
- loader = PyMuPDFLoader(file_path, mode="single")
 
 
 
 
 
 
 
 
 
17
  raw_documents = loader.load()
18
- if not raw_documents:
19
- raise ValueError(f"Couldn’t load any content from {file_path}")
20
 
21
- text_splitter = RecursiveCharacterTextSplitter(chunk_size=1700, chunk_overlap=300)
22
- split_docs = text_splitter.split_documents(raw_documents)
23
- for doc in split_docs:
24
- doc.metadata["source"] = self.manager.document_url
 
 
 
 
25
 
26
- embedding_model = GoogleGenerativeAIEmbeddings(model=EMBEDDING_MODEL)
27
- db = Chroma(
28
- collection_name="pdf_docs",
29
- embedding_function=embedding_model,
30
- persist_directory="/tmp/vector_db",
31
  )
 
 
32
 
33
- # check if already stored ANY chunk for this URL
34
- existing = db.get(
35
- ids=None,
36
- include=["metadatas"],
37
- where={"source": self.manager.document_url}
38
- )["metadatas"]
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
- retriever = db.as_retriever(
47
- search_kwargs={"k": 5, "filter": {"source": self.manager.document_url}}
48
- )
49
- return retriever
 
 
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://arxiv.org/pdf/1706.03762.pdf"
 
9
  questions = [
10
- "What is a Transformer and what are its components?",
11
- "What was the BLEU score for the big Transformer model on the English-to-German translation task shown in Table 2?",
12
- "How is the transformer model better from its predecessors?",
13
- "Which organization published the paper?"
 
 
 
 
 
 
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 Multi-Query Service Test[/bold green]",
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
- rprint(Panel("[bold green]Processing Complete. Displaying Results...[/bold green]"))
30
-
31
- for i, (response) in enumerate(results):
32
 
 
33
  question_panel = Panel(
34
- f"[bold]Answer:[/bold] {response.answer}\n\n",
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 langchain_core.vectorstores import VectorStoreRetriever
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: VectorStoreRetriever
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 an expert search query generator specializing in complex insurance policy documents.
 
 
26
 
27
- For each user question provided, create exactly 3 distinct, self-contained search queries. These queries are designed to be run against a vector database to find the most relevant text chunks.
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
- chain = prompt | structured_llm
47
- generated_lists: GeneratedQueries = chain.invoke({"questions": questions_str}) # type: ignore
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
- queries = []
63
- for query_object in state["decomposed_questions"].lst:
64
- queries.extend(query_object.queries)
65
- """
66
- batch run rather than sequential invoke
67
- queries is a list of strings
68
- N initialy queries. Total 4 queries per original questions.
69
- 4N queries in queries[].
70
- 3 Chunks are returned.
71
- docs_list->4N size. ech element containing a list of 3 Documents.
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
- contexts = [
90
- "\n\n---\n\n".join([doc.page_content for doc in docs])
91
- for docs in state["documents"]
92
- ]
93
- questions = [q.question for q in state["original_questions"]]
94
- N = len(questions)
95
-
96
- prompt = ChatPromptTemplate.from_template(
97
- """You are a meticulous and expert insurance policy analyst. Your task is to answer the user's QUESTION based *strictly and exclusively* on the provided CONTEXT from a policy document.
98
-
99
- **Instructions:**
100
- 1. **Comprehensive Analysis:** Carefully read the entire CONTEXT to find all relevant information. The answer is often spread across multiple sentences.
101
- 2. **Extract All Details:** Your answer MUST include all specific conditions, waiting periods, monetary limits, sub-limits, eligibility criteria, and quantitative details (like bed counts, percentages, or timeframes).
102
- 3. **Direct and Factual:** Begin with a direct answer to the question. Follow up with the detailed supporting information you extracted.
103
- 4. **No External Knowledge:** Do not use any information outside of the provided CONTEXT. If the context does not contain the answer, state that clearly.
104
- 5. **Be Factual, Not Conversational:** Do not add pleasantries. Stick to the facts from the policy document.
105
-
106
- CONTEXT:
107
- {context}
108
-
109
- QUESTION:
110
- {question}
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: VectorStoreRetriever)->List[FinalAnswer]:
135
  initial_state = {"original_questions": questions, "retriever": retriever}
136
- final_state = self.graph.invoke(initial_state) # type: ignore
137
- answer_objects:List[FinalAnswer] = final_state.get("answers") # type: ignore
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", [])