akryldigital commited on
Commit
89199c5
·
verified ·
1 Parent(s): 582a267

inherit from base class

Browse files
Files changed (1) hide show
  1. src/agents/multi_agent_chatbot.py +158 -1214
src/agents/multi_agent_chatbot.py CHANGED
@@ -1,115 +1,45 @@
1
  """
2
  Multi-Agent RAG Chatbot using LangGraph
3
 
4
- This system implements a 3-agent architecture:
5
- 1. Main Agent: Handles conversation flow, follow-ups, and determines when to call RAG
6
- 2. RAG Agent: Rewrites queries and applies filters for document retrieval
7
- 3. Response Agent: Generates final answers from retrieved documents
8
-
9
- Each agent has specialized prompts and responsibilities.
10
  """
11
- import re
12
- import json
13
- import time
14
  import logging
15
  import traceback
16
- from pathlib import Path
17
- from datetime import datetime
18
- from dataclasses import dataclass
19
- from typing import Dict, List, Any, Optional, TypedDict
20
 
21
- from langchain_core.tools import tool
22
- from langgraph.graph import StateGraph, END
23
  from langchain_core.prompts import ChatPromptTemplate
24
  from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
25
 
26
-
27
  from src.pipeline import PipelineManager
28
- from src.llm.adapters import get_llm_client
29
- from src.config.paths import PROJECT_DIR, CONVERSATIONS_DIR
30
- from src.config.loader import load_config, get_embedding_model_for_collection
31
-
32
 
33
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
34
  logger = logging.getLogger(__name__)
35
 
36
 
37
- @dataclass
38
- class QueryContext:
39
- """Context extracted from conversation"""
40
- has_district: bool = False
41
- has_source: bool = False
42
- has_year: bool = False
43
- extracted_district: Optional[str] = None
44
- extracted_source: Optional[str] = None
45
- extracted_year: Optional[str] = None
46
- ui_filters: Dict[str, List[str]] = None
47
- confidence_score: float = 0.0
48
- needs_follow_up: bool = False
49
- follow_up_question: Optional[str] = None
50
-
51
-
52
- class MultiAgentState(TypedDict):
53
- """State for the multi-agent conversation flow"""
54
- conversation_id: str
55
- messages: List[Any]
56
- current_query: str
57
- query_context: Optional[QueryContext]
58
- rag_query: Optional[str]
59
- rag_filters: Optional[Dict[str, Any]]
60
- retrieved_documents: Optional[List[Any]]
61
- final_response: Optional[str]
62
- agent_logs: List[str]
63
- conversation_context: Dict[str, Any]
64
- session_start_time: float
65
- last_ai_message_time: float
66
-
67
-
68
- class MultiAgentRAGChatbot:
69
- """Multi-agent RAG chatbot with specialized agents"""
70
 
71
  def __init__(self, config_path: str = "src/config/settings.yaml"):
72
- """Initialize the multi-agent chatbot"""
73
- self.config = load_config(config_path)
 
74
 
75
- # Get LLM provider from config
76
- reader_config = self.config.get("reader", {})
77
- default_type = reader_config.get("default_type", "INF_PROVIDERS")
78
- provider_name = default_type.lower()
79
-
80
- self.llm_adapter = get_llm_client(provider_name, self.config)
81
-
82
- # Create a simple wrapper for LangChain compatibility
83
- class LLMWrapper:
84
- def __init__(self, adapter):
85
- self.adapter = adapter
86
-
87
- def invoke(self, messages):
88
- # Convert LangChain messages to the format expected by the adapter
89
- if isinstance(messages, list):
90
- formatted_messages = []
91
- for msg in messages:
92
- if hasattr(msg, 'content'):
93
- role = "user" if msg.__class__.__name__ == "HumanMessage" else "assistant"
94
- formatted_messages.append({"role": role, "content": msg.content})
95
- else:
96
- formatted_messages.append({"role": "user", "content": str(msg)})
97
- else:
98
- formatted_messages = [{"role": "user", "content": str(messages)}]
99
-
100
- # Use the adapter to get response
101
- response = self.adapter.generate(formatted_messages)
102
-
103
- # Return a mock response object
104
- class MockResponse:
105
- def __init__(self, content):
106
- self.content = content
107
-
108
- return MockResponse(response.content)
109
-
110
- self.llm = LLMWrapper(self.llm_adapter)
111
-
112
- # Initialize pipeline manager early to load models
113
  logger.info("🔄 Initializing pipeline manager and loading models...")
114
  try:
115
  self.pipeline_manager = PipelineManager(self.config)
@@ -124,929 +54,44 @@ class MultiAgentRAGChatbot:
124
  try:
125
  if not self.pipeline_manager.connect_vectorstore():
126
  logger.error("❌ Failed to connect to vector store")
127
- logger.error("💡 Check that QDRANT_API_KEY environment variable is set")
128
- logger.error("💡 Check that Qdrant URL and collection name are correct in config")
129
  raise RuntimeError("Vector store connection failed")
130
  logger.info("✅ Vector store connected successfully")
131
  except RuntimeError:
132
- raise # Re-raise RuntimeError as-is
133
  except Exception as e:
134
  logger.error(f"❌ Error during vector store connection: {e}")
135
  traceback.print_exc()
136
  raise RuntimeError(f"Vector store connection failed: {e}")
137
 
138
- # Load dynamic data
139
- self._load_dynamic_data()
140
-
141
- # Build the multi-agent graph
142
- self.graph = self._build_graph()
143
-
144
- # Conversations directory - use PROJECT_DIR for local vs deployed compatibility
145
- self.conversations_dir = CONVERSATIONS_DIR
146
- try:
147
- # Use 777 permissions for maximum compatibility (HF Spaces runs as different user)
148
- self.conversations_dir.mkdir(parents=True, mode=0o777, exist_ok=True)
149
- except (PermissionError, OSError) as e:
150
- logger.warning(f"Could not create conversations directory at {self.conversations_dir}: {e}")
151
- # Fallback to a relative path (current directory)
152
- self.conversations_dir = Path("conversations")
153
- try:
154
- self.conversations_dir.mkdir(parents=True, mode=0o777, exist_ok=True)
155
- except (PermissionError, OSError) as e2:
156
- logger.error(f"Could not create conversations directory at {self.conversations_dir}: {e2}")
157
- raise RuntimeError(f"Failed to create conversations directory: {e2}")
158
-
159
- logger.info("🤖 Multi-Agent RAG Chatbot initialized")
160
-
161
- def _load_dynamic_data(self):
162
- """Load dynamic data from filter_options.json and add_district_metadata.py"""
163
- # Load filter options - use PROJECT_DIR relative path
164
- try:
165
- fo = PROJECT_DIR / "src" / "config" / "filter_options.json"
166
- if fo.exists():
167
- with open(fo) as f:
168
- data = json.load(f)
169
- self.year_whitelist = [str(y).strip() for y in data.get("years", [])]
170
- self.source_whitelist = [str(s).strip() for s in data.get("sources", [])]
171
- self.district_whitelist = [str(d).strip() for d in data.get("districts", [])]
172
- else:
173
- # Fallback to default values
174
- self.year_whitelist = ['2018', '2019', '2020', '2021', '2022', '2023', '2024']
175
- self.source_whitelist = ['Consolidated', 'Local Government', 'Ministry, Department and Agency']
176
- self.district_whitelist = ['Kampala', 'Gulu', 'Kalangala']
177
- except Exception as e:
178
- logger.warning(f"Could not load filter options: {e}")
179
- self.year_whitelist = ['2018', '2019', '2020', '2021', '2022', '2023', '2024']
180
- self.source_whitelist = ['Consolidated', 'Local Government', 'Ministry, Department and Agency']
181
- self.district_whitelist = ['Kampala', 'Gulu', 'Kalangala']
182
-
183
- # Enrich district list from add_district_metadata.py (if available)
184
- try:
185
- from add_district_metadata import DistrictMetadataProcessor
186
- proc = DistrictMetadataProcessor()
187
- names = set()
188
- for key, mapping in proc.district_mappings.items():
189
- if getattr(mapping, 'is_district', True):
190
- names.add(mapping.name)
191
- if names:
192
- merged = list(self.district_whitelist)
193
- for n in sorted(names):
194
- if n not in merged:
195
- merged.append(n)
196
- self.district_whitelist = merged
197
- logger.info(f"🧭 District whitelist enriched: {len(self.district_whitelist)} entries")
198
- except Exception as e:
199
- logger.info(f"ℹ️ Could not enrich districts: {e}")
200
-
201
- # Calculate current year dynamically
202
- self.current_year = str(datetime.now().year)
203
- self.previous_year = str(datetime.now().year - 1)
204
-
205
- # Log the actual filter values for debugging
206
- logger.info(f"📊 ACTUAL FILTER VALUES:")
207
- logger.info(f" Years: {self.year_whitelist}")
208
- logger.info(f" Sources: {self.source_whitelist}")
209
- logger.info(f" Districts: {len(self.district_whitelist)} districts (first 10: {self.district_whitelist[:10]})")
210
-
211
- def _normalize_district_name(self, district: str) -> Optional[str]:
212
- """Normalize district name with fuzzy matching for common misspellings."""
213
- if not district:
214
- return None
215
-
216
- district = district.strip()
217
-
218
- # Direct match
219
- if district in self.district_whitelist:
220
- return district
221
-
222
- # Remove "District" suffix
223
- district_name = district.replace(" District", "").replace(" district", "").strip()
224
- if district_name in self.district_whitelist:
225
- return district_name
226
-
227
- # Common misspellings mapping
228
- misspelling_map = {
229
- "kalagala": "Kalangala",
230
- "Kalagala": "Kalangala",
231
- "KALAGALA": "Kalangala",
232
- "kalangala": "Kalangala",
233
- "gulu": "Gulu",
234
- "GULU": "Gulu",
235
- "kampala": "Kampala",
236
- "KAMPALA": "Kampala",
237
- }
238
-
239
- # Check misspelling map (case-insensitive)
240
- district_lower = district_name.lower()
241
- if district_lower in misspelling_map:
242
- corrected = misspelling_map[district_lower]
243
- if corrected in self.district_whitelist:
244
- return corrected
245
-
246
- # Fuzzy matching for similar names (simple Levenshtein-like check)
247
- # Check if the district name is very similar to any whitelist entry
248
- for whitelist_district in self.district_whitelist:
249
- # Case-insensitive comparison
250
- if district_name.lower() == whitelist_district.lower():
251
- return whitelist_district
252
-
253
- # Check if one is a substring of the other (for partial matches)
254
- if len(district_name) >= 4 and len(whitelist_district) >= 4:
255
- if district_name.lower() in whitelist_district.lower() or whitelist_district.lower() in district_name.lower():
256
- # Only return if it's a strong match (at least 80% of characters match)
257
- min_len = min(len(district_name), len(whitelist_district))
258
- max_len = max(len(district_name), len(whitelist_district))
259
- if min_len / max_len >= 0.8:
260
- return whitelist_district
261
-
262
- return None
263
 
264
- def _build_graph(self) -> StateGraph:
265
- """Build the multi-agent LangGraph"""
266
- graph = StateGraph(MultiAgentState)
267
-
268
- # Add nodes for each agent
269
- graph.add_node("main_agent", self._main_agent)
270
- graph.add_node("rag_agent", self._rag_agent)
271
- graph.add_node("response_agent", self._response_agent)
272
-
273
- # Define the flow
274
- graph.set_entry_point("main_agent")
275
-
276
- # Main agent decides next step
277
- graph.add_conditional_edges(
278
- "main_agent",
279
- self._should_call_rag,
280
- {
281
- "follow_up": END,
282
- "call_rag": "rag_agent"
283
- }
284
  )
285
 
286
- # RAG agent calls response agent
287
- graph.add_edge("rag_agent", "response_agent")
288
-
289
- # Response agent returns to main agent for potential follow-ups
290
- graph.add_edge("response_agent", "main_agent")
291
-
292
- return graph.compile()
293
-
294
- def _should_call_rag(self, state: MultiAgentState) -> str:
295
- """Determine if we should call RAG or ask follow-up"""
296
- # If we already have a final response (from response agent), end
297
- if state.get("final_response"):
298
- return "follow_up"
299
-
300
- context = state["query_context"]
301
- if context and context.needs_follow_up:
302
- return "follow_up"
303
- return "call_rag"
304
 
305
- def _main_agent(self, state: MultiAgentState) -> MultiAgentState:
306
- """Main Agent: Handles conversation flow and follow-ups"""
307
- logger.info("🎯 MAIN AGENT: Starting analysis")
308
-
309
- # If we already have a final response from response agent, end gracefully
310
- if state.get("final_response"):
311
- logger.info("🎯 MAIN AGENT: Final response already exists, ending conversation flow")
312
- return state
313
-
314
- query = state["current_query"]
315
- messages = state["messages"]
316
-
317
- logger.info(f"🎯 MAIN AGENT: Extracting UI filters from query")
318
- ui_filters = self._extract_ui_filters(query)
319
- logger.info(f"🎯 MAIN AGENT: UI filters extracted: {ui_filters}")
320
-
321
- # Analyze query context
322
- logger.info(f"🎯 MAIN AGENT: Analyzing query context")
323
- context = self._analyze_query_context(query, messages, ui_filters)
324
-
325
- # Log agent decision
326
- state["agent_logs"].append(f"MAIN AGENT: Context analyzed - district={context.has_district}, source={context.has_source}, year={context.has_year}")
327
- logger.info(f"🎯 MAIN AGENT: Context analysis complete - district={context.has_district}, source={context.has_source}, year={context.has_year}")
328
-
329
- # Store context
330
- state["query_context"] = context
331
-
332
- # If follow-up needed, generate response
333
- if context.needs_follow_up:
334
- logger.info(f"🎯 MAIN AGENT: Follow-up needed, generating question")
335
- response = context.follow_up_question
336
- state["final_response"] = response
337
- state["last_ai_message_time"] = time.time()
338
- logger.info(f"🎯 MAIN AGENT: Follow-up question generated: {response[:100]}...")
339
- else:
340
- logger.info("🎯 MAIN AGENT: No follow-up needed, proceeding to RAG")
341
-
342
- return state
343
-
344
- def _rag_agent(self, state: MultiAgentState) -> MultiAgentState:
345
- """RAG Agent: Rewrites queries and applies filters"""
346
- logger.info("🔍 RAG AGENT: Starting query rewriting and filter preparation")
347
-
348
- context = state["query_context"]
349
- messages = state["messages"]
350
-
351
- logger.info(f"🔍 RAG AGENT: Context received - district={context.has_district}, source={context.has_source}, year={context.has_year}")
352
-
353
- # Rewrite query for RAG
354
- logger.info(f"🔍 RAG AGENT: Rewriting query for optimal retrieval")
355
- rag_query = self._rewrite_query_for_rag(messages, context)
356
- logger.info(f"🔍 RAG AGENT: Query rewritten: '{rag_query}'")
357
-
358
- # Build filters
359
- logger.info(f"🔍 RAG AGENT: Building filters from context")
360
- filters = self._build_filters(context)
361
- logger.info(f"🔍 RAG AGENT: Filters built: {filters}")
362
-
363
- # Log RAG preparation
364
- state["agent_logs"].append(f"RAG AGENT: Query='{rag_query}', Filters={filters}")
365
-
366
- # Store for response agent
367
- state["rag_query"] = rag_query
368
- state["rag_filters"] = filters
369
-
370
- logger.info(f"🔍 RAG AGENT: Preparation complete, ready for retrieval")
371
-
372
- return state
373
-
374
- def _response_agent(self, state: MultiAgentState) -> MultiAgentState:
375
- """Response Agent: Generates final answer from retrieved documents"""
376
- logger.info("📝 RESPONSE AGENT: Starting document retrieval and answer generation")
377
-
378
- rag_query = state["rag_query"]
379
- filters = state["rag_filters"]
380
-
381
- logger.info(f"📝 RESPONSE AGENT: Starting RAG retrieval with query: '{rag_query}'")
382
- logger.info(f"📝 RESPONSE AGENT: Using filters: {filters}")
383
-
384
- # Perform RAG retrieval
385
- logger.info(f"📝 RESPONSE AGENT: Calling pipeline manager for retrieval")
386
- logger.info(f"🔍 ACTUAL RAG QUERY: '{rag_query}'")
387
- logger.info(f"🔍 ACTUAL FILTERS: {filters}")
388
- try:
389
- # Extract filenames from filters if present
390
- filenames = filters.get("filenames") if filters else None
391
-
392
- result = self.pipeline_manager.run(
393
- query=rag_query,
394
- sources=filters.get("sources") if filters else None,
395
- auto_infer_filters=False,
396
- filters=filters if filters else None
397
- )
398
-
399
- logger.info(f"📝 RESPONSE AGENT: RAG retrieval completed - {len(result.sources)} documents retrieved")
400
- logger.info(f"🔍 RETRIEVAL DEBUG: Result type: {type(result)}")
401
- logger.info(f"🔍 RETRIEVAL DEBUG: Result sources type: {type(result.sources)}")
402
- # logger.info(f"🔍 RETRIEVAL DEBUG: Result metadata: {getattr(result, 'metadata', 'No metadata')}")
403
-
404
- if len(result.sources) == 0:
405
- logger.warning(f"⚠️ NO DOCUMENTS RETRIEVED: Query='{rag_query}', Filters={filters}")
406
- logger.warning(f"⚠️ RETRIEVAL DEBUG: This could be due to:")
407
- logger.warning(f" - Query too specific for available documents")
408
- logger.warning(f" - Filters too restrictive")
409
- logger.warning(f" - Vector store connection issues")
410
- logger.warning(f" - Embedding model issues")
411
- else:
412
- logger.info(f"✅ DOCUMENTS RETRIEVED: {len(result.sources)} documents found")
413
- for i, doc in enumerate(result.sources[:3]): # Log first 3 docs
414
- logger.info(f" Doc {i+1}: {getattr(doc, 'metadata', {}).get('filename', 'Unknown')[:50]}...")
415
-
416
- state["retrieved_documents"] = result.sources
417
- state["agent_logs"].append(f"RESPONSE AGENT: Retrieved {len(result.sources)} documents")
418
-
419
- # Check highest similarity score
420
- highest_score = 0.0
421
- if result.sources:
422
- # Check reranked_score first (more accurate), fallback to original_score
423
- for doc in result.sources:
424
- score = doc.metadata.get('reranked_score') or doc.metadata.get('original_score', 0.0)
425
- if score > highest_score:
426
- highest_score = score
427
-
428
- logger.info(f"📝 RESPONSE AGENT: Highest similarity score: {highest_score:.4f}")
429
-
430
- # If highest score is too low, don't use retrieved documents
431
- if highest_score <= 0.15:
432
- logger.warning(f"⚠️ RESPONSE AGENT: Low similarity score ({highest_score:.4f} <= 0.15), using LLM knowledge only")
433
- response = self._generate_conversational_response_without_docs(
434
- state["current_query"],
435
- state["messages"]
436
- )
437
- else:
438
- # Generate conversational response with documents
439
- logger.info(f"📝 RESPONSE AGENT: Generating conversational response from {len(result.sources)} documents")
440
- response = self._generate_conversational_response(
441
- state["current_query"],
442
- result.sources,
443
- result.answer,
444
- state["messages"]
445
- )
446
-
447
- logger.info(f"📝 RESPONSE AGENT: Response generated: {response[:100]}...")
448
-
449
- state["final_response"] = response
450
- state["last_ai_message_time"] = time.time()
451
-
452
- logger.info(f"📝 RESPONSE AGENT: Answer generation complete")
453
-
454
- except Exception as e:
455
- logger.error(f"❌ RESPONSE AGENT ERROR: {e}")
456
- state["final_response"] = "I apologize, but I encountered an error while retrieving information. Please try again."
457
- state["last_ai_message_time"] = time.time()
458
-
459
- return state
460
-
461
- def _extract_ui_filters(self, query: str) -> Dict[str, List[str]]:
462
- """Extract UI filters from query"""
463
- filters = {}
464
-
465
- # Look for FILTER CONTEXT in query
466
- if "FILTER CONTEXT:" in query:
467
- # Extract the entire filter section (until USER QUERY: or end of query)
468
- filter_section = query.split("FILTER CONTEXT:")[1]
469
- if "USER QUERY:" in filter_section:
470
- filter_section = filter_section.split("USER QUERY:")[0]
471
- filter_section = filter_section.strip()
472
-
473
- # Parse sources
474
- if "Sources:" in filter_section:
475
- sources_line = [line for line in filter_section.split('\n') if line.strip().startswith('Sources:')][0]
476
- sources_str = sources_line.split("Sources:")[1].strip()
477
- if sources_str and sources_str != "None":
478
- filters["sources"] = [s.strip() for s in sources_str.split(",")]
479
-
480
- # Parse years
481
- if "Years:" in filter_section:
482
- years_line = [line for line in filter_section.split('\n') if line.strip().startswith('Years:')][0]
483
- years_str = years_line.split("Years:")[1].strip()
484
- if years_str and years_str != "None":
485
- filters["years"] = [y.strip() for y in years_str.split(",")]
486
-
487
- # Parse districts
488
- if "Districts:" in filter_section:
489
- districts_line = [line for line in filter_section.split('\n') if line.strip().startswith('Districts:')][0]
490
- districts_str = districts_line.split("Districts:")[1].strip()
491
- if districts_str and districts_str != "None":
492
- filters["districts"] = [d.strip() for d in districts_str.split(",")]
493
-
494
- # Parse filenames
495
- if "Filenames:" in filter_section:
496
- filenames_line = [line for line in filter_section.split('\n') if line.strip().startswith('Filenames:')][0]
497
- filenames_str = filenames_line.split("Filenames:")[1].strip()
498
- if filenames_str and filenames_str != "None":
499
- filters["filenames"] = [f.strip() for f in filenames_str.split(",")]
500
-
501
- return filters
502
-
503
- def _analyze_query_context(self, query: str, messages: List[Any], ui_filters: Dict[str, List[str]]) -> QueryContext:
504
- """Analyze query context using LLM"""
505
- logger.info(f"🔍 QUERY ANALYSIS: '{query[:50]}...' | UI filters: {ui_filters} | Messages: {len(messages)}")
506
-
507
- # Build conversation context
508
- conversation_context = ""
509
- for i, msg in enumerate(messages[-6:]): # Last 6 messages
510
- if isinstance(msg, HumanMessage):
511
- conversation_context += f"User: {msg.content}\n"
512
- elif isinstance(msg, AIMessage):
513
- conversation_context += f"Assistant: {msg.content}\n"
514
-
515
- # Create analysis prompt
516
- analysis_prompt = ChatPromptTemplate.from_messages([
517
- SystemMessage(content=f"""You are the Main Agent in an advanced multi-agent RAG system for audit report analysis.
518
-
519
- 🎯 PRIMARY GOAL: Intelligently analyze user queries and determine the optimal conversation flow, whether that's answering directly, asking follow-ups, or proceeding to RAG retrieval.
520
-
521
- 🧠 INTELLIGENCE LEVEL: You are a sophisticated conversational AI that can handle any type of user interaction - from greetings to complex audit queries.
522
-
523
- 📊 YOUR EXPERTISE: You specialize in analyzing audit reports from various sources (Local Government, Ministry, Hospital, etc.) across different years and districts in Uganda.
524
-
525
- 🔍 AVAILABLE FILTERS:
526
- - Years: {', '.join(self.year_whitelist)}
527
- - Current year: {self.current_year}, Previous year: {self.previous_year}
528
- - Sources: {', '.join(self.source_whitelist)}
529
- - Districts: {', '.join(self.district_whitelist[:50])}... (and {len(self.district_whitelist)-50} more)
530
-
531
- 🎛️ UI FILTERS PROVIDED: {ui_filters}
532
-
533
- 📋 UI FILTER HANDLING:
534
- - If UI filters contain multiple values (e.g., districts: ['Lwengo', 'Kiboga']), extract ALL values
535
- - For multiple districts: extract each district separately and validate each one
536
- - For multiple years: extract each year separately and validate each one
537
- - For multiple sources: extract each source separately and validate each one
538
- - UI filters take PRIORITY over conversation context - use them first
539
-
540
- 🧭 CONVERSATION FLOW INTELLIGENCE:
541
-
542
- 1. **GREETINGS & GENERAL CHAT**:
543
- - If user greets you ("Hi", "Hello", "How are you"), respond warmly and guide them to audit-related questions
544
- - Example: "Hello! I'm here to help you analyze audit reports. What would you like to know about budget allocations, expenditures, or audit findings?"
545
-
546
- 2. **EDGE CASES**:
547
- - Handle "What can you do?", "Help", "I don't know what to ask" with helpful guidance
548
- - Example: "I can help you analyze audit reports! Try asking about budget allocations, salary management, PDM implementation, or any specific audit findings."
549
-
550
- 3. **AUDIT QUERIES**:
551
- - Extract ONLY values that EXACTLY match the available lists above
552
- - DO NOT hallucinate or infer values not in the lists
553
- - If user mentions "salary payroll management" - this is NOT a valid source filter
554
-
555
- **YEAR EXTRACTION**:
556
- - If user mentions "2023" and it's in the years list - extract "2023"
557
- - If user mentions "2022 / 23" - extract ["2022", "2023"] (as a JSON array)
558
- - If user mentions "2022-2023" - extract ["2022", "2023"] (as a JSON array)
559
- - If user mentions "latest couple of years" - extract the 2 most recent years from available data as JSON array
560
- - Always return years as JSON arrays when multiple years are mentioned
561
-
562
- **DISTRICT EXTRACTION**:
563
- - If user mentions "Kampala" and it's in the districts list - extract "Kampala"
564
- - If user mentions "Pader District" - extract "Pader" (remove "District" suffix)
565
- - If user mentions "Lwengo, Kiboga and Namutumba" - extract ["Lwengo", "Kiboga", "Namutumba"] (as JSON array)
566
- - If user mentions "Lwengo District and Kiboga District" - extract ["Lwengo", "Kiboga"] (as JSON array, remove "District" suffix)
567
- - Always return districts as JSON arrays when multiple districts are mentioned
568
- - **COMMON MISSPELLINGS**: Handle common misspellings intelligently:
569
- * "Kalagala" (missing 'n') should be extracted as "Kalangala"
570
- * "kalagala", "Kalagala", "KALAGALA" should all be normalized to "Kalangala"
571
- * Similar case-insensitive variations should be normalized to the correct district name
572
- - If no exact matches found, set extracted values to null
573
-
574
- 4. **FILENAME FILTERING (MUTUALLY EXCLUSIVE)**:
575
- - If UI provides filenames filter - ONLY use that, ignore all other filters (year, district, source)
576
- - With filenames filter, no follow-ups needed - proceed directly to RAG
577
- - When filenames are specified, skip filter inference entirely
578
-
579
- 5. **HALLUCINATION PREVENTION**:
580
- - If user asks about a specific report but NO filename is selected in UI and NONE is extracted from conversation - DO NOT hallucinate
581
- - Clearly state: "I don't have any specific report selected. Could you please select a report from the list or tell me which report you'd like to analyze?"
582
- - DO NOT pretend to know which report they mean
583
- - DO NOT infer reports from context alone - only use explicitly mentioned reports
584
-
585
- 6. **CONVERSATION CONTEXT AWARENESS**:
586
- - ALWAYS consider the full conversation context when extracting filters
587
- - If district was mentioned in previous messages, include it in current analysis
588
- - If year was mentioned in previous messages, include it in current analysis
589
- - If source was mentioned in previous messages, include it in current analysis
590
- - Example: If conversation shows "User: Tell me about Pader District" then "User: 2023", extract both: district="Pader" and year="2023"
591
-
592
- 5. **SMART FOLLOW-UP STRATEGY**:
593
- - NEVER ask the same question twice in a row
594
- - If user provides source info, ask for year or district next
595
- - If user provides year info, ask for source or district next
596
- - If user provides district info, ask for year or source next
597
- - If user provides 2+ pieces of info, proceed to RAG instead of asking more
598
- - Make follow-ups conversational and contextual, not robotic
599
-
600
- 5. **DYNAMIC FOLLOW-UP EXAMPLES**:
601
- - Budget queries: "What year are you interested in?" or "Which department - Local Government or Ministry?"
602
- - PDM queries: "Which district are you interested in?" or "What year?"
603
- - General queries: "Could you be more specific about what you'd like to know?"
604
-
605
- 🎯 DECISION LOGIC:
606
- - If query is a greeting/general chat → needs_follow_up: true, provide helpful guidance
607
- - If query has 2+ pieces of info → needs_follow_up: false, proceed to RAG
608
- - If query has 1 piece of info → needs_follow_up: true, ask for missing piece
609
- - If query has 0 pieces of info → needs_follow_up: true, ask for clarification
610
-
611
- RESPOND WITH JSON ONLY:
612
- {{
613
- "has_district": boolean,
614
- "has_source": boolean,
615
- "has_year": boolean,
616
- "extracted_district": "single district name or JSON array of districts or null",
617
- "extracted_source": "single source name or JSON array of sources or null",
618
- "extracted_year": "single year or JSON array of years or null",
619
- "confidence_score": 0.0-1.0,
620
- "needs_follow_up": boolean,
621
- "follow_up_question": "conversational question or helpful guidance or null"
622
- }}"""),
623
- HumanMessage(content=f"""Query: {query}
624
-
625
- Conversation Context:
626
- {conversation_context}
627
-
628
- CRITICAL: You MUST analyze the FULL conversation context above, not just the current query.
629
- - If ANY district was mentioned in previous messages, extract it
630
- - If ANY year was mentioned in previous messages, extract it
631
- - If ANY source was mentioned in previous messages, extract it
632
- - Combine information from ALL messages in the conversation
633
-
634
- Analyze this query using ONLY the exact values provided above:""")
635
- ])
636
-
637
- try:
638
- response = self.llm.invoke(analysis_prompt.format_messages())
639
-
640
- # Clean the response to extract JSON
641
- content = response.content.strip()
642
- if content.startswith("```json"):
643
- # Remove markdown formatting
644
- content = content.replace("```json", "").replace("```", "").strip()
645
- elif content.startswith("```"):
646
- # Remove generic markdown formatting
647
- content = content.replace("```", "").strip()
648
-
649
- # Clean and parse JSON with better error handling
650
- try:
651
- # Remove comments (// and /* */) from JSON
652
- # Remove single-line comments
653
- content = re.sub(r'//.*?$', '', content, flags=re.MULTILINE)
654
- # Remove multi-line comments
655
- content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
656
-
657
- analysis = json.loads(content)
658
- logger.info(f"🔍 QUERY ANALYSIS: ✅ Parsed successfully")
659
- except json.JSONDecodeError as e:
660
- logger.error(f"❌ JSON parsing failed: {e}")
661
- logger.error(f"❌ Raw content: {content[:200]}...")
662
-
663
- # Try to extract JSON from text if embedded
664
- json_match = re.search(r'\{.*\}', content, re.DOTALL)
665
- if json_match:
666
- try:
667
- # Clean the extracted JSON
668
- cleaned_json = json_match.group()
669
- cleaned_json = re.sub(r'//.*?$', '', cleaned_json, flags=re.MULTILINE)
670
- cleaned_json = re.sub(r'/\*.*?\*/', '', cleaned_json, flags=re.DOTALL)
671
- analysis = json.loads(cleaned_json)
672
- logger.info(f"🔍 QUERY ANALYSIS: ✅ Extracted and cleaned JSON from text")
673
- except json.JSONDecodeError as e2:
674
- logger.error(f"❌ Failed to extract JSON from text: {e2}")
675
- # Return fallback context
676
- context = QueryContext(
677
- has_district=False,
678
- has_source=False,
679
- has_year=False,
680
- extracted_district=None,
681
- extracted_source=None,
682
- extracted_year=None,
683
- confidence_score=0.0,
684
- needs_follow_up=True,
685
- follow_up_question="I apologize, but I'm having trouble processing your request. Could you please rephrase it or ask for help?"
686
- )
687
- return context
688
- else:
689
- # Return fallback context
690
- context = QueryContext(
691
- has_district=False,
692
- has_source=False,
693
- has_year=False,
694
- extracted_district=None,
695
- extracted_source=None,
696
- extracted_year=None,
697
- confidence_score=0.0,
698
- needs_follow_up=True,
699
- follow_up_question="I apologize, but I'm having trouble processing your request. Could you please rephrase it or ask for help?"
700
- )
701
- return context
702
-
703
- # Validate extracted values against whitelists
704
- extracted_district = analysis.get("extracted_district")
705
- extracted_source = analysis.get("extracted_source")
706
- extracted_year = analysis.get("extracted_year")
707
-
708
- logger.info(f"🔍 QUERY ANALYSIS: Raw extracted values - district: {extracted_district}, source: {extracted_source}, year: {extracted_year}")
709
-
710
- # Validate district (handle both single values and arrays)
711
- if extracted_district:
712
- if isinstance(extracted_district, list):
713
- # Validate each district in the array
714
- valid_districts = []
715
- for district in extracted_district:
716
- normalized = self._normalize_district_name(district)
717
- if normalized:
718
- valid_districts.append(normalized)
719
-
720
- if valid_districts:
721
- extracted_district = valid_districts[0] if len(valid_districts) == 1 else valid_districts
722
- logger.info(f"🔍 QUERY ANALYSIS: Extracted districts: {extracted_district}")
723
- else:
724
- logger.warning(f"⚠️ No valid districts found in: '{extracted_district}'")
725
- extracted_district = None
726
- else:
727
- # Single district validation with fuzzy matching
728
- normalized = self._normalize_district_name(extracted_district)
729
- if normalized:
730
- if normalized != extracted_district:
731
- logger.info(f"🔍 QUERY ANALYSIS: Normalized district '{extracted_district}' to '{normalized}'")
732
- extracted_district = normalized
733
- else:
734
- logger.warning(f"⚠️ Invalid district extracted: '{extracted_district}' not in whitelist")
735
- extracted_district = None
736
-
737
- # Validate source (handle both single values and arrays)
738
- if extracted_source:
739
- if isinstance(extracted_source, list):
740
- # Validate each source in the array
741
- valid_sources = []
742
- for source in extracted_source:
743
- if source in self.source_whitelist:
744
- valid_sources.append(source)
745
- else:
746
- logger.warning(f"⚠️ Invalid source in array: '{source}' not in whitelist")
747
-
748
- if valid_sources:
749
- extracted_source = valid_sources[0] if len(valid_sources) == 1 else valid_sources
750
- logger.info(f"🔍 QUERY ANALYSIS: Extracted sources: {extracted_source}")
751
- else:
752
- logger.warning(f"⚠️ No valid sources found in: '{extracted_source}'")
753
- extracted_source = None
754
- else:
755
- # Single source validation
756
- if extracted_source not in self.source_whitelist:
757
- logger.warning(f"⚠️ Invalid source extracted: '{extracted_source}' not in whitelist")
758
- extracted_source = None
759
-
760
- # Validate year (handle both single values and arrays)
761
- if extracted_year:
762
- if isinstance(extracted_year, list):
763
- # Validate each year in the array
764
- valid_years = []
765
- for year in extracted_year:
766
- year_str = str(year)
767
- if year_str in self.year_whitelist:
768
- valid_years.append(year_str)
769
-
770
- if valid_years:
771
- extracted_year = valid_years[0] if len(valid_years) == 1 else valid_years
772
- logger.info(f"🔍 QUERY ANALYSIS: Extracted years: {extracted_year}")
773
- else:
774
- logger.warning(f"⚠️ No valid years found in: '{extracted_year}'")
775
- extracted_year = None
776
- else:
777
- # Single year validation
778
- year_str = str(extracted_year)
779
- if year_str not in self.year_whitelist:
780
- logger.warning(f"⚠️ Invalid year extracted: '{extracted_year}' not in whitelist")
781
- extracted_year = None
782
- else:
783
- extracted_year = year_str
784
-
785
- logger.info(f"🔍 QUERY ANALYSIS: Validated values - district: {extracted_district}, source: {extracted_source}, year: {extracted_year}")
786
-
787
- # Create QueryContext object
788
- context = QueryContext(
789
- has_district=bool(extracted_district),
790
- has_source=bool(extracted_source),
791
- has_year=bool(extracted_year),
792
- extracted_district=extracted_district,
793
- extracted_source=extracted_source,
794
- extracted_year=extracted_year,
795
- ui_filters=ui_filters,
796
- confidence_score=analysis.get("confidence_score", 0.0),
797
- needs_follow_up=analysis.get("needs_follow_up", False),
798
- follow_up_question=analysis.get("follow_up_question")
799
- )
800
-
801
- logger.info(f"🔍 QUERY ANALYSIS: Analysis complete - needs_follow_up: {context.needs_follow_up}, confidence: {context.confidence_score}")
802
-
803
- # If filenames are provided in UI, skip follow-ups and proceed to RAG
804
- if ui_filters and ui_filters.get("filenames"):
805
- logger.info(f"🔍 QUERY ANALYSIS: Filenames provided, skipping follow-ups, proceeding to RAG")
806
- context.needs_follow_up = False
807
- context.follow_up_question = None
808
-
809
- # Additional smart decision logic
810
- if context.needs_follow_up:
811
- # Check if we have enough information to proceed
812
- info_count = sum([
813
- bool(context.extracted_district),
814
- bool(context.extracted_source),
815
- bool(context.extracted_year)
816
- ])
817
-
818
- # Check if user is asking for more info vs providing it
819
- query_lower = query.lower()
820
- is_requesting_info = any(phrase in query_lower for phrase in [
821
- "please provide", "could you provide", "can you provide",
822
- "what is", "what are", "how much", "which", "what year",
823
- "what district", "what source", "tell me about"
824
- ])
825
-
826
- # If we have 2+ pieces of info AND user is not requesting more info, proceed to RAG
827
- if info_count >= 2 and not is_requesting_info:
828
- logger.info(f"🔍 QUERY ANALYSIS: Smart override - have {info_count} pieces of info and user not requesting more, proceeding to RAG")
829
- context.needs_follow_up = False
830
- context.follow_up_question = None
831
- elif info_count >= 2 and is_requesting_info:
832
- logger.info(f"🔍 QUERY ANALYSIS: User requesting more info despite having {info_count} pieces, proceeding to RAG with comprehensive answer")
833
- context.needs_follow_up = False
834
- context.follow_up_question = None
835
-
836
- return context
837
-
838
- except Exception as e:
839
- logger.error(f"❌ Query analysis failed: {e}")
840
- # Fallback: proceed with RAG
841
- return QueryContext(
842
- has_district=bool(ui_filters.get("districts")),
843
- has_source=bool(ui_filters.get("sources")),
844
- has_year=bool(ui_filters.get("years")),
845
- ui_filters=ui_filters,
846
- confidence_score=0.5,
847
- needs_follow_up=False
848
- )
849
-
850
- def _rewrite_query_for_rag(self, messages: List[Any], context: QueryContext) -> str:
851
- """Rewrite query for optimal RAG retrieval"""
852
- logger.info("🔄 QUERY REWRITING: Starting query rewrite for RAG")
853
- logger.info(f"🔄 QUERY REWRITING: Processing {len(messages)} messages")
854
-
855
- # Build conversation context
856
- logger.info(f"🔄 QUERY REWRITING: Building conversation context from last 6 messages")
857
- conversation_lines = []
858
- for i, msg in enumerate(messages[-6:]):
859
- if isinstance(msg, HumanMessage):
860
- conversation_lines.append(f"User: {msg.content}")
861
- logger.info(f"🔄 QUERY REWRITING: Message {i+1}: User - {msg.content[:50]}...")
862
- elif isinstance(msg, AIMessage):
863
- conversation_lines.append(f"Assistant: {msg.content}")
864
- logger.info(f"🔄 QUERY REWRITING: Message {i+1}: Assistant - {msg.content[:50]}...")
865
-
866
- convo_text = "\n".join(conversation_lines)
867
- logger.info(f"🔄 QUERY REWRITING: Conversation context built ({len(convo_text)} chars)")
868
-
869
- # Create rewrite prompt
870
- rewrite_prompt = ChatPromptTemplate.from_messages([
871
- SystemMessage(content=f"""You are a query rewriter for RAG retrieval.
872
-
873
- GOAL: Create the best possible search query for document retrieval.
874
-
875
- CRITICAL RULES:
876
- 1. Focus on the core information need from the conversation
877
- 2. Remove meta-verbs like "summarize", "list", "compare", "how much", "what" - keep the content focus
878
- 3. DO NOT include filter details (years, districts, sources) - these are applied separately as filters
879
- 4. DO NOT include specific years, district names, or source types in the query
880
- 5. Output ONE clear sentence suitable for vector search
881
- 6. Keep it generic and focused on the topic/subject matter
882
-
883
- EXAMPLES:
884
- - "What are the top challenges in budget allocation?" → "budget allocation challenges"
885
- - "How were PDM administrative costs utilized in 2023?" → "PDM administrative costs utilization"
886
- - "Compare salary management across districts" → "salary management"
887
- - "How much was budget allocation for Local Government in 2023?" → "budget allocation"
888
-
889
- OUTPUT FORMAT:
890
- Provide your response in this exact format:
891
-
892
- EXPLANATION: [Your reasoning here]
893
- QUERY: [One clean sentence for retrieval]
894
-
895
- The QUERY line will be extracted and used directly for RAG retrieval."""),
896
- HumanMessage(content=f"""Conversation:
897
- {convo_text}
898
-
899
- Rewrite the best retrieval query:""")
900
- ])
901
-
902
- try:
903
- logger.info(f"🔄 QUERY REWRITING: Calling LLM for query rewrite")
904
- response = self.llm.invoke(rewrite_prompt.format_messages())
905
- logger.info(f"🔄 QUERY REWRITING: LLM response received: {response.content[:100]}...")
906
-
907
- rewritten = response.content.strip()
908
-
909
- # Extract only the QUERY line from the structured response
910
- lines = rewritten.split('\n')
911
- query_line = None
912
- for line in lines:
913
- if line.strip().startswith('QUERY:'):
914
- query_line = line.replace('QUERY:', '').strip()
915
- break
916
-
917
- if query_line and len(query_line) > 5:
918
- logger.info(f"🔄 QUERY REWRITING: Query rewritten successfully: '{query_line[:50]}...'")
919
- return query_line
920
- else:
921
- logger.info(f"🔄 QUERY REWRITING: No QUERY line found or too short, using fallback")
922
- # Fallback to last user message
923
- for msg in reversed(messages):
924
- if isinstance(msg, HumanMessage):
925
- logger.info(f"🔄 QUERY REWRITING: Using fallback message: '{msg.content[:50]}...'")
926
- return msg.content
927
- logger.info(f"🔄 QUERY REWRITING: Using default fallback")
928
- return "audit report information"
929
-
930
- except Exception as e:
931
- logger.error(f"❌ QUERY REWRITING: Error during rewrite: {e}")
932
- # Fallback
933
- for msg in reversed(messages):
934
- if isinstance(msg, HumanMessage):
935
- logger.info(f"🔄 QUERY REWRITING: Using error fallback message: '{msg.content[:50]}...'")
936
- return msg.content
937
- logger.info(f"🔄 QUERY REWRITING: Using default error fallback")
938
- return "audit report information"
939
-
940
- def _build_filters(self, context: QueryContext) -> Dict[str, Any]:
941
- """Build filters for RAG retrieval"""
942
- logger.info("🔧 FILTER BUILDING: Starting filter construction")
943
- filters = {}
944
-
945
- # Check for filename filtering first (mutually exclusive)
946
- if context.ui_filters and context.ui_filters.get("filenames"):
947
- logger.info(f"🔧 FILTER BUILDING: Filename filtering requested (mutually exclusive mode)")
948
- filters["filenames"] = context.ui_filters["filenames"]
949
- logger.info(f"🔧 FILTER BUILDING: Added filenames filter: {context.ui_filters['filenames']}")
950
- logger.info(f"🔧 FILTER BUILDING: Final filters: {filters}")
951
- return filters # Return early, skip all other filters
952
-
953
- # UI filters take priority, but merge with extracted context if UI filters are incomplete
954
- if context.ui_filters:
955
- logger.info(f"🔧 FILTER BUILDING: UI filters present: {context.ui_filters}")
956
-
957
- # Add UI filters first
958
- if context.ui_filters.get("sources"):
959
- filters["sources"] = context.ui_filters["sources"]
960
- logger.info(f"🔧 FILTER BUILDING: Added sources filter from UI: {context.ui_filters['sources']}")
961
-
962
- if context.ui_filters.get("years"):
963
- filters["year"] = context.ui_filters["years"]
964
- logger.info(f"🔧 FILTER BUILDING: Added years filter from UI: {context.ui_filters['years']}")
965
-
966
- if context.ui_filters.get("districts"):
967
- # Normalize district names to title case (match Qdrant metadata format)
968
- normalized_districts = [d.title() for d in context.ui_filters['districts']]
969
- filters["district"] = normalized_districts
970
- logger.info(f"🔧 FILTER BUILDING: Added districts filter from UI: {context.ui_filters['districts']} → normalized: {normalized_districts}")
971
-
972
- # Merge with extracted context for missing filters
973
- if not filters.get("district") and context.extracted_district:
974
- # Normalize district names using the normalization function
975
- if isinstance(context.extracted_district, list):
976
- normalized_districts = []
977
- for d in context.extracted_district:
978
- normalized = self._normalize_district_name(d)
979
- if normalized:
980
- normalized_districts.append(normalized)
981
- if normalized_districts:
982
- filters["district"] = normalized_districts
983
- logger.info(f"🔧 FILTER BUILDING: Added districts filter from context: {context.extracted_district} → normalized: {normalized_districts}")
984
- else:
985
- normalized = self._normalize_district_name(context.extracted_district)
986
- if normalized:
987
- filters["district"] = [normalized]
988
- logger.info(f"🔧 FILTER BUILDING: Added district filter from context: {context.extracted_district} → normalized: {normalized}")
989
-
990
- if not filters.get("year") and context.extracted_year:
991
- # Handle both single values and arrays
992
- if isinstance(context.extracted_year, list):
993
- filters["year"] = context.extracted_year
994
- else:
995
- filters["year"] = [context.extracted_year]
996
- logger.info(f"🔧 FILTER BUILDING: Added extracted year filter (UI missing): {context.extracted_year}")
997
-
998
- if not filters.get("sources") and context.extracted_source:
999
- # Handle both single values and arrays
1000
- if isinstance(context.extracted_source, list):
1001
- filters["sources"] = context.extracted_source
1002
- else:
1003
- filters["sources"] = [context.extracted_source]
1004
- logger.info(f"🔧 FILTER BUILDING: Added extracted source filter (UI missing): {context.extracted_source}")
1005
- else:
1006
- logger.info(f"🔧 FILTER BUILDING: No UI filters, using extracted context")
1007
- # Use extracted context
1008
- if context.extracted_source:
1009
- # Handle both single values and arrays
1010
- if isinstance(context.extracted_source, list):
1011
- filters["sources"] = context.extracted_source
1012
- else:
1013
- filters["sources"] = [context.extracted_source]
1014
- logger.info(f"🔧 FILTER BUILDING: Added extracted source filter: {context.extracted_source}")
1015
-
1016
- if context.extracted_year:
1017
- # Handle both single values and arrays
1018
- if isinstance(context.extracted_year, list):
1019
- filters["year"] = context.extracted_year
1020
- else:
1021
- filters["year"] = [context.extracted_year]
1022
- logger.info(f"🔧 FILTER BUILDING: Added extracted year filter: {context.extracted_year}")
1023
-
1024
- if context.extracted_district:
1025
- # Normalize district names using the normalization function
1026
- if isinstance(context.extracted_district, list):
1027
- normalized_districts = []
1028
- for d in context.extracted_district:
1029
- normalized = self._normalize_district_name(d)
1030
- if normalized:
1031
- normalized_districts.append(normalized)
1032
- if normalized_districts:
1033
- filters["district"] = normalized_districts
1034
- logger.info(f"🔧 FILTER BUILDING: Added districts filter from context: {context.extracted_district} → normalized: {normalized_districts}")
1035
- else:
1036
- normalized = self._normalize_district_name(context.extracted_district)
1037
- if normalized:
1038
- filters["district"] = [normalized]
1039
- logger.info(f"🔧 FILTER BUILDING: Added district filter from context: {context.extracted_district} → normalized: {normalized}")
1040
-
1041
- logger.info(f"🔧 FILTER BUILDING: Final filters: {filters}")
1042
- return filters
1043
-
1044
- def _generate_conversational_response(self, query: str, documents: List[Any], rag_answer: str, messages: List[Any]) -> str:
1045
  """Generate conversational response from RAG results"""
1046
  logger.info("💬 RESPONSE GENERATION: Starting conversational response generation")
1047
- logger.info(f"💬 RESPONSE GENERATION: Processing {len(documents)} documents")
1048
- logger.info(f"💬 RESPONSE GENERATION: Query: '{query[:50]}...'")
1049
- logger.info(f"💬 RESPONSE GENERATION: Conversation history: {len(messages)} messages")
1050
 
1051
  # Build conversation history context
1052
  conversation_context = self._build_conversation_context(messages)
@@ -1054,11 +99,10 @@ Rewrite the best retrieval query:""")
1054
  # Build detailed document information
1055
  document_details = self._build_document_details(documents)
1056
 
1057
- # Extract correct district/source/year names from documents (to correct misspellings)
1058
  correct_names = self._extract_correct_names_from_documents(documents)
1059
 
1060
  # Create response prompt
1061
- logger.info(f"💬 RESPONSE GENERATION: Building response prompt")
1062
  response_prompt = ChatPromptTemplate.from_messages([
1063
  SystemMessage(content="""You are a helpful audit report assistant. Generate a natural, conversational response.
1064
 
@@ -1068,7 +112,7 @@ CRITICAL RULES - NO HALLUCINATION:
1068
  3. **If a document doesn't contain the information, DO NOT make it up**
1069
  4. **If the user asks about a year/district that's NOT in the retrieved documents, explicitly state that**
1070
  5. **Check the document years/districts before making any claims about them**
1071
- 6. **USE CORRECT NAMES**: If the conversation mentions a misspelled district/source name (e.g., "Kalagala"), use the CORRECT spelling from the document metadata (e.g., "Kalangala"). Always use the exact names from document metadata, not misspellings from conversation.
1072
 
1073
  RULES:
1074
  1. Answer the user's question directly and clearly
@@ -1086,9 +130,8 @@ RULES:
1086
  13. You do not need to use every passage. Only use the ones that help answer the question.
1087
  14. **VERIFY**: Before mentioning any year, district, or number, check that it exists in the retrieved documents. If it doesn't, say "I don't have information about [year/district] in the retrieved documents."
1088
  15. **NO HALLUCINATION**: If documents show years 2021, 2022, 2023 but user asks about 2020, DO NOT provide 2020 data. Instead say "The retrieved documents cover 2021-2023, but I don't have information for 2020."
1089
- 16. **USE CORRECT SPELLING**: Always use the district/source names exactly as they appear in the document metadata below, even if the conversation history has misspellings.
1090
 
1091
- TONE: Professional but friendly, like talking to a colleague."""),
1092
  HumanMessage(content=f"""Conversation History:
1093
  {conversation_context}
1094
 
@@ -1096,7 +139,7 @@ Current User Question: {query}
1096
 
1097
  Retrieved Documents: {len(documents)} documents found
1098
 
1099
- CORRECT NAMES TO USE (from document metadata - use these exact spellings):
1100
  {correct_names}
1101
 
1102
  Full Document Details:
@@ -1109,28 +152,65 @@ CRITICAL:
1109
  - If user asks about a specific year but documents show other years, or districts or sources then explicitly state "can't provide response on ... because ..."
1110
  - Every factual claim MUST have [Doc i] reference
1111
  - If information is not in documents, explicitly state it's not available
1112
- - **USE THE CORRECT DISTRICT/SOURCE NAMES from the document metadata above, not misspellings from conversation**
1113
 
1114
  Generate a conversational response with proper document references:""")
1115
  ])
1116
 
1117
  try:
1118
- logger.info(f"💬 RESPONSE GENERATION: Calling LLM for final response")
1119
  response = self.llm.invoke(response_prompt.format_messages())
1120
- logger.info(f"💬 RESPONSE GENERATION: LLM response received: {response.content[:100]}...")
1121
 
1122
  # Post-process response to ensure no hallucination
1123
  final_response = self._validate_and_enhance_response(
1124
  response.content.strip(),
1125
  documents,
1126
- query
 
1127
  )
1128
 
1129
  return final_response
1130
  except Exception as e:
1131
- logger.error(f"❌ RESPONSE GENERATION: Error during generation: {e}")
1132
- logger.info(f"💬 RESPONSE GENERATION: Using RAG answer as fallback")
1133
- return rag_answer # Fallback to RAG answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1134
 
1135
  def _build_conversation_context(self, messages: List[Any]) -> str:
1136
  """Build conversation history context for response generation."""
@@ -1138,7 +218,6 @@ Generate a conversational response with proper document references:""")
1138
  return "No previous conversation."
1139
 
1140
  context_lines = []
1141
- # Show last 6 messages for context (to capture the current exchange)
1142
  for msg in messages[-6:]:
1143
  if isinstance(msg, HumanMessage):
1144
  context_lines.append(f"User: {msg.content}")
@@ -1153,7 +232,7 @@ Generate a conversational response with proper document references:""")
1153
  return "No documents retrieved."
1154
 
1155
  details = []
1156
- for i, doc in enumerate(documents[:15], 1): # Show up to 15 documents
1157
  metadata = getattr(doc, 'metadata', {}) if hasattr(doc, 'metadata') else (doc if isinstance(doc, dict) else {})
1158
  content = getattr(doc, 'page_content', '') if hasattr(doc, 'page_content') else (doc.get('content', '') if isinstance(doc, dict) else '')
1159
 
@@ -1171,7 +250,7 @@ Generate a conversational response with proper document references:""")
1171
  doc_info += f"\n Source: {source}"
1172
  if page != 'Unknown':
1173
  doc_info += f"\n Page: {page}"
1174
- doc_info += f"\n Content: {content[:300]}{'...' if len(content) > 300 else ''}"
1175
  details.append(doc_info)
1176
 
1177
  return "\n\n".join(details) if details else "No document details available."
@@ -1200,16 +279,18 @@ Generate a conversational response with proper document references:""")
1200
  if years:
1201
  result.append(f"Years: {', '.join(sorted(years))}")
1202
 
1203
- if result:
1204
- return "\n".join(result) + "\n\nIMPORTANT: Use these EXACT spellings in your response, even if the conversation history has misspellings."
1205
- return "No metadata available."
1206
 
1207
- def _validate_and_enhance_response(self, response: str, documents: List[Any], query: str) -> str:
1208
- """Validate response and ensure all claims are referenced."""
1209
- # Extract years and districts from documents
 
 
 
 
 
1210
  doc_years = set()
1211
  doc_districts = set()
1212
- doc_sources = set()
1213
 
1214
  for doc in documents:
1215
  metadata = getattr(doc, 'metadata', {}) if hasattr(doc, 'metadata') else (doc if isinstance(doc, dict) else {})
@@ -1218,204 +299,67 @@ Generate a conversational response with proper document references:""")
1218
  doc_years.add(str(metadata['year']))
1219
  if metadata.get('district'):
1220
  doc_districts.add(str(metadata['district']))
1221
- if metadata.get('source'):
1222
- doc_sources.add(str(metadata['source']))
1223
-
1224
- # Correct misspellings in response using correct names from documents
1225
- # response = self._correct_misspellings_in_response(response, doc_districts, doc_sources)
1226
 
1227
- # Check if response mentions years not in documents
1228
- year_pattern = r'\b(20\d{2})\b'
1229
- mentioned_years = set(re.findall(year_pattern, response))
1230
-
1231
- # Check if user query mentions a year
1232
- query_years = set(re.findall(year_pattern, query))
1233
-
1234
- # If user asks about a year not in documents, add a warning
1235
- missing_years = query_years - doc_years
1236
- if missing_years and doc_years:
1237
- warning = f"\n\n⚠️ Note: The retrieved documents cover years {', '.join(sorted(doc_years))}, but I don't have information for {', '.join(sorted(missing_years))} in the retrieved documents."
1238
- if warning not in response:
1239
- response = response + warning
1240
-
1241
- # Check if response has document references
1242
- doc_ref_pattern = r'\[Doc\s+\d+\]'
1243
- has_refs = bool(re.search(doc_ref_pattern, response))
1244
-
1245
- # If response has factual claims but no references, add a note
1246
- if not has_refs and len(documents) > 0:
1247
- # Check if response has numbers or specific claims (simple heuristic)
1248
- has_numbers = bool(re.search(r'\d+', response))
1249
- if has_numbers and len(response) > 50:
1250
- logger.warning("⚠️ Response contains factual claims but no document references")
1251
- # Don't modify response, but log the issue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1252
 
1253
  return response
1254
 
1255
- def _generate_conversational_response_without_docs(self, query: str, messages: List[Any]) -> str:
1256
- """Generate conversational response using only LLM knowledge and conversation history"""
1257
- logger.info("💬 RESPONSE GENERATION (NO DOCS): Starting response generation without documents")
1258
- logger.info(f"💬 RESPONSE GENERATION (NO DOCS): Query: '{query[:50]}...'")
1259
-
1260
- # Build conversation context
1261
- conversation_context = ""
1262
- for i, msg in enumerate(messages[-6:]): # Last 6 messages for context
1263
- if isinstance(msg, HumanMessage):
1264
- conversation_context += f"User: {msg.content}\n"
1265
- elif isinstance(msg, AIMessage):
1266
- conversation_context += f"Assistant: {msg.content}\n"
1267
-
1268
- # Create response prompt
1269
- logger.info(f"💬 RESPONSE GENERATION (NO DOCS): Building response prompt")
1270
- response_prompt = ChatPromptTemplate.from_messages([
1271
- SystemMessage(content="""You are a helpful audit report assistant. Generate a natural, conversational response.
1272
-
1273
- RULES:
1274
- 1. Answer the user's question directly and clearly based on your knowledge
1275
- 2. Use conversation history for context
1276
- 3. Be conversational, not technical
1277
- 4. Acknowledge if the answer is based on general knowledge rather than specific documents
1278
- 5. Stay professional but friendly
1279
-
1280
- TONE: Professional but friendly, like talking to a colleague."""),
1281
- HumanMessage(content=f"""Current Question: {query}
1282
-
1283
- Conversation History:
1284
- {conversation_context}
1285
-
1286
- Generate a conversational response based on your knowledge:""")
1287
- ])
1288
-
1289
- try:
1290
- logger.info(f"💬 RESPONSE GENERATION (NO DOCS): Calling LLM")
1291
- response = self.llm.invoke(response_prompt.format_messages())
1292
- logger.info(f"💬 RESPONSE GENERATION (NO DOCS): LLM response received: {response.content[:100]}...")
1293
- return response.content.strip()
1294
- except Exception as e:
1295
- logger.error(f"❌ RESPONSE GENERATION (NO DOCS): Error during generation: {e}")
1296
- return "I apologize, but I encountered an error. Please try asking your question differently."
1297
-
1298
- def chat(self, user_input: str, conversation_id: str = "default") -> Dict[str, Any]:
1299
- """Main chat interface"""
1300
- logger.info(f"💬 MULTI-AGENT CHAT: Processing '{user_input[:50]}...'")
1301
-
1302
- # Load conversation
1303
- logger.info(f"💬 MULTI-AGENT CHAT: Loading conversation {conversation_id}")
1304
- conversation_file = self.conversations_dir / f"{conversation_id}.json"
1305
- conversation = self._load_conversation(conversation_file)
1306
- logger.info(f"💬 MULTI-AGENT CHAT: Loaded {len(conversation['messages'])} previous messages")
1307
-
1308
- # Add user message
1309
- conversation["messages"].append(HumanMessage(content=user_input))
1310
- logger.info(f"💬 MULTI-AGENT CHAT: Added user message to conversation")
1311
-
1312
- # Prepare state
1313
- logger.info(f"💬 MULTI-AGENT CHAT: Preparing state for graph execution")
1314
- state = MultiAgentState(
1315
- conversation_id=conversation_id,
1316
- messages=conversation["messages"],
1317
- current_query=user_input,
1318
- query_context=None,
1319
- rag_query=None,
1320
- rag_filters=None,
1321
- retrieved_documents=None,
1322
- final_response=None,
1323
- agent_logs=[],
1324
- conversation_context=conversation.get("context", {}),
1325
- session_start_time=conversation["session_start_time"],
1326
- last_ai_message_time=conversation["last_ai_message_time"]
1327
- )
1328
-
1329
- # Run multi-agent graph
1330
- logger.info(f"💬 MULTI-AGENT CHAT: Executing multi-agent graph")
1331
- final_state = self.graph.invoke(state)
1332
- logger.info(f"💬 MULTI-AGENT CHAT: Graph execution completed")
1333
-
1334
- # Add AI response to conversation
1335
- if final_state["final_response"]:
1336
- conversation["messages"].append(AIMessage(content=final_state["final_response"]))
1337
- logger.info(f"💬 MULTI-AGENT CHAT: Added AI response to conversation")
1338
-
1339
- # Update conversation
1340
- conversation["last_ai_message_time"] = final_state["last_ai_message_time"]
1341
- conversation["context"] = final_state["conversation_context"]
1342
-
1343
- # Save conversation
1344
- logger.info(f"💬 MULTI-AGENT CHAT: Saving conversation")
1345
- self._save_conversation(conversation_file, conversation)
1346
-
1347
- logger.info("✅ MULTI-AGENT CHAT: Completed")
1348
-
1349
- # Return response and RAG results
1350
- return {
1351
- 'response': final_state["final_response"],
1352
- 'rag_result': {
1353
- 'sources': final_state["retrieved_documents"] or [],
1354
- 'answer': final_state["final_response"]
1355
- },
1356
- 'agent_logs': final_state["agent_logs"],
1357
- 'actual_rag_query': final_state.get("rag_query", "")
1358
- }
1359
-
1360
- def _load_conversation(self, conversation_file: Path) -> Dict[str, Any]:
1361
- """Load conversation from file"""
1362
- if conversation_file.exists():
1363
- try:
1364
- with open(conversation_file) as f:
1365
- data = json.load(f)
1366
- # Convert message dicts back to LangChain messages
1367
- messages = []
1368
- for msg_data in data.get("messages", []):
1369
- if msg_data["type"] == "human":
1370
- messages.append(HumanMessage(content=msg_data["content"]))
1371
- elif msg_data["type"] == "ai":
1372
- messages.append(AIMessage(content=msg_data["content"]))
1373
- data["messages"] = messages
1374
- return data
1375
- except Exception as e:
1376
- logger.warning(f"Could not load conversation: {e}")
1377
-
1378
- # Return default conversation
1379
- return {
1380
- "messages": [],
1381
- "session_start_time": time.time(),
1382
- "last_ai_message_time": time.time(),
1383
- "context": {}
1384
- }
1385
-
1386
- def _save_conversation(self, conversation_file: Path, conversation: Dict[str, Any]):
1387
- """Save conversation to file"""
1388
- try:
1389
- # Ensure the conversations directory exists with proper permissions
1390
- conversation_file.parent.mkdir(parents=True, mode=0o777, exist_ok=True)
1391
-
1392
- # Convert messages to serializable format
1393
- messages_data = []
1394
- for msg in conversation["messages"]:
1395
- if isinstance(msg, HumanMessage):
1396
- messages_data.append({"type": "human", "content": msg.content})
1397
- elif isinstance(msg, AIMessage):
1398
- messages_data.append({"type": "ai", "content": msg.content})
1399
-
1400
- conversation_data = {
1401
- "messages": messages_data,
1402
- "session_start_time": conversation["session_start_time"],
1403
- "last_ai_message_time": conversation["last_ai_message_time"],
1404
- "context": conversation.get("context", {})
1405
- }
1406
-
1407
- with open(conversation_file, 'w') as f:
1408
- json.dump(conversation_data, f, indent=2)
1409
-
1410
- except Exception as e:
1411
- logger.error(f"Could not save conversation: {e}")
1412
- logger.error(f"Traceback: {traceback.format_exc()}")
1413
-
1414
 
1415
  def get_multi_agent_chatbot():
1416
  """Get multi-agent chatbot instance"""
1417
  return MultiAgentRAGChatbot()
1418
 
 
1419
  if __name__ == "__main__":
1420
  # Test the multi-agent system
1421
  chatbot = MultiAgentRAGChatbot()
 
1
  """
2
  Multi-Agent RAG Chatbot using LangGraph
3
 
4
+ This is the TEXT-BASED RAG chatbot that inherits from BaseMultiAgentChatbot.
5
+ It implements the retrieval using the PipelineManager (Qdrant + text embeddings).
 
 
 
 
6
  """
 
 
 
7
  import logging
8
  import traceback
9
+ from typing import Dict, List, Any
 
 
 
10
 
 
 
11
  from langchain_core.prompts import ChatPromptTemplate
12
  from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
13
 
14
+ from src.agents.base_multi_agent_chatbot import BaseMultiAgentChatbot
15
  from src.pipeline import PipelineManager
 
 
 
 
16
 
17
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
18
  logger = logging.getLogger(__name__)
19
 
20
 
21
+ class MultiAgentRAGChatbot(BaseMultiAgentChatbot):
22
+ """
23
+ Text-based Multi-agent RAG chatbot.
24
+
25
+ Inherits all the sophisticated logic from BaseMultiAgentChatbot:
26
+ - LLM-based query analysis
27
+ - Filter extraction and validation
28
+ - Query rewriting
29
+ - Main agent, RAG agent, Response agent
30
+
31
+ Implements:
32
+ - _perform_retrieval(): Uses PipelineManager for text-based RAG
33
+ - _generate_conversational_response(): Text-focused response generation
34
+ - _generate_conversational_response_without_docs(): Fallback response
35
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  def __init__(self, config_path: str = "src/config/settings.yaml"):
38
+ """Initialize the text-based multi-agent chatbot"""
39
+ # Initialize base class first (loads config, LLM, filters, builds graph)
40
+ super().__init__(config_path)
41
 
42
+ # Initialize pipeline manager for text-based retrieval
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  logger.info("🔄 Initializing pipeline manager and loading models...")
44
  try:
45
  self.pipeline_manager = PipelineManager(self.config)
 
54
  try:
55
  if not self.pipeline_manager.connect_vectorstore():
56
  logger.error("❌ Failed to connect to vector store")
 
 
57
  raise RuntimeError("Vector store connection failed")
58
  logger.info("✅ Vector store connected successfully")
59
  except RuntimeError:
60
+ raise
61
  except Exception as e:
62
  logger.error(f"❌ Error during vector store connection: {e}")
63
  traceback.print_exc()
64
  raise RuntimeError(f"Vector store connection failed: {e}")
65
 
66
+ logger.info("🤖 Text-based Multi-Agent RAG Chatbot initialized")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ def _perform_retrieval(self, query: str, filters: Dict[str, Any]) -> Any:
69
+ """
70
+ Perform text-based retrieval using PipelineManager.
71
+
72
+ Args:
73
+ query: The rewritten query
74
+ filters: The filters to apply
75
+
76
+ Returns:
77
+ PipelineResult with .sources and .answer
78
+ """
79
+ logger.info(f"🔍 TEXT RETRIEVAL: Query='{query}', Filters={filters}")
80
+
81
+ result = self.pipeline_manager.run(
82
+ query=query,
83
+ sources=filters.get("sources") if filters else None,
84
+ auto_infer_filters=False,
85
+ filters=filters if filters else None
 
 
86
  )
87
 
88
+ logger.info(f"🔍 TEXT RETRIEVAL: Retrieved {len(result.sources)} documents")
89
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
+ def _generate_conversational_response(self, query: str, documents: List[Any], rag_answer: str, messages: List[Any], filters: Dict[str, Any] = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  """Generate conversational response from RAG results"""
93
  logger.info("💬 RESPONSE GENERATION: Starting conversational response generation")
94
+ logger.info(f"💬 RESPONSE GENERATION: Filters for validation: {filters}")
 
 
95
 
96
  # Build conversation history context
97
  conversation_context = self._build_conversation_context(messages)
 
99
  # Build detailed document information
100
  document_details = self._build_document_details(documents)
101
 
102
+ # Extract correct district/source/year names from documents
103
  correct_names = self._extract_correct_names_from_documents(documents)
104
 
105
  # Create response prompt
 
106
  response_prompt = ChatPromptTemplate.from_messages([
107
  SystemMessage(content="""You are a helpful audit report assistant. Generate a natural, conversational response.
108
 
 
112
  3. **If a document doesn't contain the information, DO NOT make it up**
113
  4. **If the user asks about a year/district that's NOT in the retrieved documents, explicitly state that**
114
  5. **Check the document years/districts before making any claims about them**
115
+ 6. **USE CORRECT NAMES**: Use the CORRECT spelling from the document metadata
116
 
117
  RULES:
118
  1. Answer the user's question directly and clearly
 
130
  13. You do not need to use every passage. Only use the ones that help answer the question.
131
  14. **VERIFY**: Before mentioning any year, district, or number, check that it exists in the retrieved documents. If it doesn't, say "I don't have information about [year/district] in the retrieved documents."
132
  15. **NO HALLUCINATION**: If documents show years 2021, 2022, 2023 but user asks about 2020, DO NOT provide 2020 data. Instead say "The retrieved documents cover 2021-2023, but I don't have information for 2020."
 
133
 
134
+ TONE: Professional but friendly."""),
135
  HumanMessage(content=f"""Conversation History:
136
  {conversation_context}
137
 
 
139
 
140
  Retrieved Documents: {len(documents)} documents found
141
 
142
+ CORRECT NAMES TO USE (from document metadata):
143
  {correct_names}
144
 
145
  Full Document Details:
 
152
  - If user asks about a specific year but documents show other years, or districts or sources then explicitly state "can't provide response on ... because ..."
153
  - Every factual claim MUST have [Doc i] reference
154
  - If information is not in documents, explicitly state it's not available
 
155
 
156
  Generate a conversational response with proper document references:""")
157
  ])
158
 
159
  try:
 
160
  response = self.llm.invoke(response_prompt.format_messages())
 
161
 
162
  # Post-process response to ensure no hallucination
163
  final_response = self._validate_and_enhance_response(
164
  response.content.strip(),
165
  documents,
166
+ query,
167
+ filters # Pass filters for coverage validation
168
  )
169
 
170
  return final_response
171
  except Exception as e:
172
+ logger.error(f"❌ RESPONSE GENERATION: Error: {e}")
173
+ return rag_answer
174
+
175
+ def _generate_conversational_response_without_docs(self, query: str, messages: List[Any]) -> str:
176
+ """Generate conversational response using only LLM knowledge"""
177
+ logger.info("💬 RESPONSE GENERATION (NO DOCS): Starting response generation without documents")
178
+
179
+ # Build conversation context
180
+ conversation_context = ""
181
+ for msg in messages[-6:]:
182
+ if isinstance(msg, HumanMessage):
183
+ conversation_context += f"User: {msg.content}\n"
184
+ elif isinstance(msg, AIMessage):
185
+ conversation_context += f"Assistant: {msg.content}\n"
186
+
187
+ response_prompt = ChatPromptTemplate.from_messages([
188
+ SystemMessage(content="""You are a helpful audit report assistant. Generate a natural, conversational response.
189
+
190
+ RULES:
191
+ 1. Answer the user's question directly and clearly based on your knowledge
192
+ 2. Use conversation history for context
193
+ 3. Be conversational, not technical
194
+ 4. Acknowledge if the answer is based on general knowledge rather than specific documents
195
+ 5. Stay professional but friendly
196
+
197
+ TONE: Professional but friendly."""),
198
+ HumanMessage(content=f"""Current Question: {query}
199
+
200
+ Conversation History:
201
+ {conversation_context}
202
+
203
+ Generate a conversational response based on your knowledge:""")
204
+ ])
205
+
206
+ try:
207
+ response = self.llm.invoke(response_prompt.format_messages())
208
+ return response.content.strip()
209
+ except Exception as e:
210
+ logger.error(f"❌ RESPONSE GENERATION (NO DOCS): Error: {e}")
211
+ return "I apologize, but I encountered an error. Please try asking your question differently."
212
+
213
+ # ==================== HELPER METHODS ====================
214
 
215
  def _build_conversation_context(self, messages: List[Any]) -> str:
216
  """Build conversation history context for response generation."""
 
218
  return "No previous conversation."
219
 
220
  context_lines = []
 
221
  for msg in messages[-6:]:
222
  if isinstance(msg, HumanMessage):
223
  context_lines.append(f"User: {msg.content}")
 
232
  return "No documents retrieved."
233
 
234
  details = []
235
+ for i, doc in enumerate(documents[:15], 1):
236
  metadata = getattr(doc, 'metadata', {}) if hasattr(doc, 'metadata') else (doc if isinstance(doc, dict) else {})
237
  content = getattr(doc, 'page_content', '') if hasattr(doc, 'page_content') else (doc.get('content', '') if isinstance(doc, dict) else '')
238
 
 
250
  doc_info += f"\n Source: {source}"
251
  if page != 'Unknown':
252
  doc_info += f"\n Page: {page}"
253
+ doc_info += f"\n Content: {content[:500]}{'...' if len(content) > 500 else ''}"
254
  details.append(doc_info)
255
 
256
  return "\n\n".join(details) if details else "No document details available."
 
279
  if years:
280
  result.append(f"Years: {', '.join(sorted(years))}")
281
 
282
+ return "\n".join(result) if result else "No metadata available."
 
 
283
 
284
+ def _validate_and_enhance_response(self, response: str, documents: List[Any], query: str, filters: Dict[str, Any] = None) -> str:
285
+ """Validate response and ensure all claims are referenced.
286
+
287
+ Compares REQUESTED filters against RETRIEVED document metadata to identify gaps.
288
+ """
289
+ import re
290
+
291
+ # Extract years and districts from RETRIEVED documents
292
  doc_years = set()
293
  doc_districts = set()
 
294
 
295
  for doc in documents:
296
  metadata = getattr(doc, 'metadata', {}) if hasattr(doc, 'metadata') else (doc if isinstance(doc, dict) else {})
 
299
  doc_years.add(str(metadata['year']))
300
  if metadata.get('district'):
301
  doc_districts.add(str(metadata['district']))
 
 
 
 
 
302
 
303
+ logger.info(f"📊 VALIDATION: Retrieved docs cover years={doc_years}, districts={doc_districts}")
304
+
305
+ warnings = []
306
+
307
+ # Get REQUESTED filters
308
+ requested_years = set()
309
+ requested_districts = set()
310
+
311
+ if filters:
312
+ if filters.get('year'):
313
+ requested_years = set(str(y) for y in filters['year']) if isinstance(filters['year'], list) else {str(filters['year'])}
314
+ if filters.get('district'):
315
+ requested_districts = set(str(d) for d in filters['district']) if isinstance(filters['district'], list) else {str(filters['district'])}
316
+
317
+ logger.info(f"📊 VALIDATION: Requested years={requested_years}, districts={requested_districts}")
318
+
319
+ # Compare requested vs retrieved YEARS
320
+ if requested_years and doc_years:
321
+ missing_years = requested_years - doc_years
322
+ if missing_years:
323
+ warnings.append(f"You requested data for years {', '.join(sorted(requested_years))}, but the retrieved documents only cover {', '.join(sorted(doc_years))}. Data for {', '.join(sorted(missing_years))} may not be available in the database.")
324
+ elif requested_years and not doc_years:
325
+ warnings.append(f"You requested data for years {', '.join(sorted(requested_years))}, but no documents were retrieved with year metadata.")
326
+
327
+ # Compare requested vs retrieved DISTRICTS
328
+ if requested_districts and doc_districts:
329
+ # Normalize for comparison (case-insensitive)
330
+ requested_districts_lower = {d.lower() for d in requested_districts}
331
+ doc_districts_lower = {d.lower() for d in doc_districts}
332
+ missing_districts_lower = requested_districts_lower - doc_districts_lower
333
+
334
+ if missing_districts_lower:
335
+ # Get original case versions for display
336
+ missing_districts = [d for d in requested_districts if d.lower() in missing_districts_lower]
337
+ warnings.append(f"You requested data for districts {', '.join(sorted(requested_districts))}, but the retrieved documents only cover {', '.join(sorted(doc_districts))}. Data for {', '.join(sorted(missing_districts))} may not be available in the database.")
338
+ elif requested_districts and not doc_districts:
339
+ warnings.append(f"You requested data for districts {', '.join(sorted(requested_districts))}, but no documents were retrieved with district metadata.")
340
+
341
+ # Fallback: Check query text for explicit years not in documents (for cases without filters)
342
+ if not requested_years:
343
+ year_pattern = r'\b(20\d{2})\b'
344
+ query_years = set(re.findall(year_pattern, query))
345
+ missing_years = query_years - doc_years
346
+ if missing_years and doc_years:
347
+ warnings.append(f"The retrieved documents cover years {', '.join(sorted(doc_years))}, but I don't have information for {', '.join(sorted(missing_years))} in the retrieved documents.")
348
+
349
+ # Add warnings to response if any
350
+ if warnings and "⚠️" not in response:
351
+ warning_text = "\n\n⚠️ **Note:** " + " ".join(warnings)
352
+ response = response + warning_text
353
+ logger.info(f"📊 VALIDATION: Added warning: {warning_text}")
354
 
355
  return response
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
 
358
  def get_multi_agent_chatbot():
359
  """Get multi-agent chatbot instance"""
360
  return MultiAgentRAGChatbot()
361
 
362
+
363
  if __name__ == "__main__":
364
  # Test the multi-agent system
365
  chatbot = MultiAgentRAGChatbot()