arka7 commited on
Commit
bcbc40e
·
1 Parent(s): e8d6c32

Implement SOTA Agentic RAG: Guardrail, CRAG, Self-RAG, Parent-Document retrieval, and Gemini 3.6 Flash migration

Browse files
agents/decision.py CHANGED
@@ -1,54 +1,50 @@
1
  from typing import List, Tuple
2
- from core.llm import llm_router, llm_breaker
3
  from core.logger import logger
4
 
5
  async def run_decision_agent(query: str, chat_history: List = None) -> Tuple[bool, str]:
6
  """
7
- Decision Agent - Decides if web search is needed.
8
- Returns: (needs_search: bool, reasoning: str)
9
  """
10
  logger.info("Running Decision Agent")
11
 
12
  if chat_history is None:
13
  chat_history = []
14
 
15
- # Check if we have previous answers
16
  if not chat_history or len(chat_history) <= 1:
17
  logger.info("First query: Search is required")
18
  return True, "First query - need fresh data"
19
 
20
- # Build conversation context
21
  context = "Previous Conversation:\n"
22
- for msg in chat_history[-6:]: # Last 3 exchanges
23
  if isinstance(msg, dict):
24
  role = msg.get("role", "").upper()
25
- content = msg.get("content", "")[:200]
26
  else:
27
  try:
28
  role = "USER" if msg[0] else "ASSISTANT"
29
- content = (msg[0] or msg[1] or "")[:200]
30
  except Exception:
31
  continue
32
  context += f"{role}: {content}\n"
33
 
34
- # Prepare messages
35
  messages = [
36
  {
37
  "role": "system",
38
  "content": (
39
- "You are a decision agent for e-commerce queries.\n\n"
40
- "Analyze if the current query can be answered from PREVIOUS conversation context or if it needs NEW web search.\n\n"
41
- "Rules:\n"
42
- "1. If query asks about SPECIFIC products/prices/comparisons from before -> NO search needed, use previous context\n"
43
- "2. If query asks for DIFFERENT products or NEW information -> YES search needed\n"
44
- "3. If query is a follow-up question about something already discussed -> NO search needed\n"
45
- "4. If unclear or asking for LATEST data -> YES search needed\n\n"
46
- "Respond with ONLY: \"SEARCH\" or \"CONTEXT\""
47
  )
48
  },
49
  {
50
  "role": "user",
51
- "content": f"{context}\n\nCurrent query: {query}\n\nDecision: (respond with ONLY \"SEARCH\" or \"CONTEXT\")"
52
  }
53
  ]
54
 
@@ -58,16 +54,23 @@ async def run_decision_agent(query: str, chat_history: List = None) -> Tuple[boo
58
 
59
  try:
60
  response = await llm_router.acompletion(
61
- model="mistral-small",
62
  messages=messages,
63
  temperature=0.0
64
  )
65
  decision = response.choices[0].message.content.strip().upper()
66
  llm_breaker.record_success()
67
 
68
- needs_search = "SEARCH" in decision
 
 
 
 
 
 
 
69
  reasoning = f"Decision: {'Will search web' if needs_search else 'Will use previous context'}"
70
- logger.info(f"Decision Agent reasoning: {reasoning}")
71
  return needs_search, reasoning
72
  except Exception as e:
73
  llm_breaker.record_failure()
 
1
  from typing import List, Tuple
2
+ from core.llm import llm_router, llm_breaker, PRIMARY_MODEL
3
  from core.logger import logger
4
 
5
  async def run_decision_agent(query: str, chat_history: List = None) -> Tuple[bool, str]:
6
  """
7
+ Determines whether a new web search is required or existing context is sufficient.
 
8
  """
9
  logger.info("Running Decision Agent")
10
 
11
  if chat_history is None:
12
  chat_history = []
13
 
 
14
  if not chat_history or len(chat_history) <= 1:
15
  logger.info("First query: Search is required")
16
  return True, "First query - need fresh data"
17
 
 
18
  context = "Previous Conversation:\n"
19
+ for msg in chat_history[-6:]:
20
  if isinstance(msg, dict):
21
  role = msg.get("role", "").upper()
22
+ content = msg.get("content", "")[:1200]
23
  else:
24
  try:
25
  role = "USER" if msg[0] else "ASSISTANT"
26
+ content = (msg[0] or msg[1] or "")[:1200]
27
  except Exception:
28
  continue
29
  context += f"{role}: {content}\n"
30
 
 
31
  messages = [
32
  {
33
  "role": "system",
34
  "content": (
35
+ "You are an intelligent decision agent for an e-commerce assistant.\n\n"
36
+ "Your task: Decide if the current user query requires a NEW WEB SEARCH or if it can be answered using the EXISTING CONVERSATION and indexed knowledge base.\n\n"
37
+ "CRITICAL ROUTING RULES:\n"
38
+ "1. If the user asks a follow-up question referencing previously discussed products, options, or recommendations (e.g., 'tell me the specs of the above products', 'give details on those laptops', 'compare them', 'which one should I buy', 'how is the battery of the first one?') -> Output 'CONTEXT'. DO NOT search the web because the full specifications are already available in the knowledge base and conversation.\n"
39
+ "2. If the user asks for clarifications, price comparisons, or pros/cons of products already mentioned -> Output 'CONTEXT'.\n"
40
+ "3. If the user asks about an entirely DIFFERENT product category, brand, or new item NOT mentioned in the conversation -> Output 'SEARCH'.\n"
41
+ "4. If the user explicitly asks for 'new/unrelated products' or wants to restart search -> Output 'SEARCH'.\n\n"
42
+ "Output ONLY one word: either 'CONTEXT' or 'SEARCH'."
43
  )
44
  },
45
  {
46
  "role": "user",
47
+ "content": f"{context}\n\nCurrent User Query: {query}\n\nDecision (Respond with ONLY 'CONTEXT' or 'SEARCH'):"
48
  }
49
  ]
50
 
 
54
 
55
  try:
56
  response = await llm_router.acompletion(
57
+ model=PRIMARY_MODEL,
58
  messages=messages,
59
  temperature=0.0
60
  )
61
  decision = response.choices[0].message.content.strip().upper()
62
  llm_breaker.record_success()
63
 
64
+ # Robust check: prioritize CONTEXT if mentioned or if it starts with CONTEXT
65
+ if "CONTEXT" in decision:
66
+ needs_search = False
67
+ elif "SEARCH" in decision:
68
+ needs_search = True
69
+ else:
70
+ needs_search = False
71
+
72
  reasoning = f"Decision: {'Will search web' if needs_search else 'Will use previous context'}"
73
+ logger.info(f"Decision Agent: {decision} -> {reasoning}")
74
  return needs_search, reasoning
75
  except Exception as e:
76
  llm_breaker.record_failure()
agents/graders.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import List, Dict, Any
3
+ from core.llm import llm_router, GUARDRAIL_MODEL, llm_breaker
4
+ from core.logger import logger
5
+
6
+ async def grade_retrieved_documents(query: str, retrieved_chunks: List[Dict[str, Any]]) -> Dict[str, Any]:
7
+ """
8
+ Evaluate retrieved chunks for query relevance and filter noise.
9
+ """
10
+ if not retrieved_chunks:
11
+ return {
12
+ "relevant_chunks": [],
13
+ "is_sufficient": False,
14
+ "filter_ratio": "0/0",
15
+ "reasoning": "No retrieved documents available."
16
+ }
17
+
18
+ logger.info(f"CRAG Grader: Evaluating {len(retrieved_chunks)} retrieved chunks for query: '{query[:60]}'")
19
+
20
+ if not llm_breaker.can_execute():
21
+ logger.warning("LLM Circuit Breaker OPEN. Skipping CRAG grading, keeping all chunks.")
22
+ return {
23
+ "relevant_chunks": retrieved_chunks,
24
+ "is_sufficient": True,
25
+ "filter_ratio": f"{len(retrieved_chunks)}/{len(retrieved_chunks)}",
26
+ "reasoning": "Circuit breaker bypass."
27
+ }
28
+
29
+ chunk_summaries = []
30
+ for i, ch in enumerate(retrieved_chunks[:6]):
31
+ meta = ch.get("metadata", {})
32
+ title = meta.get("title") or meta.get("source", "Document")
33
+ snippet = (ch.get("document") or "")[:250].replace("\n", " ")
34
+ chunk_summaries.append(f"[{i}] Title/Source: {title} | Snippet: {snippet}")
35
+
36
+ context_str = "\n".join(chunk_summaries)
37
+
38
+ system_prompt = (
39
+ "You are a Corrective RAG (CRAG) document relevance grader for an e-commerce platform.\n"
40
+ "Your job is to assess whether the provided retrieved snippets contain information relevant to the user query.\n"
41
+ "A snippet is RELEVANT if it contains information about the products, models, prices, features, categories, "
42
+ "or buying advice related to the user's shopping request.\n\n"
43
+ "Output JSON only with this exact structure:\n"
44
+ "{\n"
45
+ " \"relevant_indices\": [0, 1, ...],\n"
46
+ " \"is_sufficient\": true/false,\n"
47
+ " \"reasoning\": \"<brief 1 sentence explanation>\"\n"
48
+ "}"
49
+ )
50
+
51
+ user_prompt = f"User Query: {query}\n\nCandidate Document Snippets:\n{context_str}"
52
+
53
+ try:
54
+ response = await llm_router.acompletion(
55
+ model=GUARDRAIL_MODEL,
56
+ messages=[
57
+ {"role": "system", "content": system_prompt},
58
+ {"role": "user", "content": user_prompt}
59
+ ],
60
+ temperature=0.0,
61
+ response_format={"type": "json_object"}
62
+ )
63
+ content = response.choices[0].message.content.strip()
64
+
65
+ if content.startswith("```"):
66
+ content = content.split("```")[1]
67
+ if content.startswith("json"):
68
+ content = content[4:]
69
+ data = json.loads(content)
70
+
71
+ relevant_indices = data.get("relevant_indices", [])
72
+ is_sufficient = bool(data.get("is_sufficient", len(relevant_indices) > 0))
73
+ reasoning = data.get("reasoning", "")
74
+
75
+ filtered = [
76
+ retrieved_chunks[idx]
77
+ for idx in relevant_indices
78
+ if isinstance(idx, int) and 0 <= idx < len(retrieved_chunks)
79
+ ]
80
+
81
+ if not filtered and retrieved_chunks:
82
+ filtered = retrieved_chunks[:2]
83
+ is_sufficient = True
84
+
85
+ filter_ratio = f"{len(filtered)}/{len(retrieved_chunks)}"
86
+ logger.info(f"CRAG Grader: Kept {filter_ratio} relevant chunks. Sufficient: {is_sufficient}. Reasoning: {reasoning}")
87
+
88
+ return {
89
+ "relevant_chunks": filtered,
90
+ "is_sufficient": is_sufficient,
91
+ "filter_ratio": filter_ratio,
92
+ "reasoning": reasoning
93
+ }
94
+ except Exception as e:
95
+ logger.warning(f"CRAG document grading failed: {e}. Retaining all chunks as fallback.")
96
+ return {
97
+ "relevant_chunks": retrieved_chunks,
98
+ "is_sufficient": True,
99
+ "filter_ratio": f"{len(retrieved_chunks)}/{len(retrieved_chunks)}",
100
+ "reasoning": "Fallback on grader error"
101
+ }
102
+
103
+
104
+ async def grade_hallucination_and_grounding(
105
+ query: str,
106
+ answer: str,
107
+ context_chunks: List[Dict[str, Any]]
108
+ ) -> Dict[str, Any]:
109
+ """
110
+ Verify factual claims and pricing in the generated response against retrieved context.
111
+ """
112
+ if not answer or not context_chunks:
113
+ return {
114
+ "is_grounded": True,
115
+ "grounding_score": 100,
116
+ "flags": [],
117
+ "badge_markdown": ""
118
+ }
119
+
120
+ logger.info(f"Self-RAG: Verifying grounding and price faithfulness of synthesis answer.")
121
+
122
+ if not llm_breaker.can_execute():
123
+ return {
124
+ "is_grounded": True,
125
+ "grounding_score": 90,
126
+ "flags": [],
127
+ "badge_markdown": "> 🛡️ **Self-RAG Grounding Verification:** Circuit breaker active; context retrieved from verified retail indexes."
128
+ }
129
+
130
+ context_snippets = []
131
+ for i, ch in enumerate(context_chunks[:4]):
132
+ snippet = (ch.get("document") or "")[:400].replace("\n", " ")
133
+ context_snippets.append(f"Doc[{i+1}]: {snippet}")
134
+ context_text = "\n".join(context_snippets)
135
+
136
+ answer_snippet = answer[:1500]
137
+
138
+ system_prompt = (
139
+ "You are a Self-RAG Hallucination and Factuality Grader for an e-commerce assistant.\n"
140
+ "Assess whether the synthesized answer's factual claims, product specifications, and prices (₹ INR) "
141
+ "are grounded in the provided context documents or reasonable inferences from them.\n\n"
142
+ "Output JSON only with this structure:\n"
143
+ "{\n"
144
+ " \"is_grounded\": true/false,\n"
145
+ " \"grounding_score\": 0-100,\n"
146
+ " \"flags\": [\"<any major hallucination or price discrepancy, if found>\"],\n"
147
+ " \"summary\": \"<short verdict>\"\n"
148
+ "}"
149
+ )
150
+
151
+ user_prompt = f"User Query: {query}\n\nContext Documents:\n{context_text}\n\nSynthesized Answer:\n{answer_snippet}"
152
+
153
+ try:
154
+ response = await llm_router.acompletion(
155
+ model=GUARDRAIL_MODEL,
156
+ messages=[
157
+ {"role": "system", "content": system_prompt},
158
+ {"role": "user", "content": user_prompt}
159
+ ],
160
+ temperature=0.0,
161
+ response_format={"type": "json_object"}
162
+ )
163
+ content = response.choices[0].message.content.strip()
164
+
165
+ if content.startswith("```"):
166
+ content = content.split("```")[1]
167
+ if content.startswith("json"):
168
+ content = content[4:]
169
+ data = json.loads(content)
170
+
171
+ is_grounded = bool(data.get("is_grounded", True))
172
+ score = int(data.get("grounding_score", 90))
173
+ flags = data.get("flags", [])
174
+
175
+ if is_grounded and score >= 75:
176
+ badge = f"> 🛡️ **Self-RAG Verified ({score}% Grounding Score):** Product specifications and pricing verified against retrieved e-commerce sources."
177
+ elif flags:
178
+ badge = f"> ⚠️ **Self-RAG Notice ({score}% Grounding Score):** Please note: {'; '.join(flags[:2])}. Verify exact live pricing on the retailer page."
179
+ else:
180
+ badge = f"> ℹ️ **Self-RAG Verified:** Grounded on market research and retrieved product data."
181
+
182
+ logger.info(f"Self-RAG result: Grounded={is_grounded}, Score={score}, Flags={flags}")
183
+
184
+ return {
185
+ "is_grounded": is_grounded,
186
+ "grounding_score": score,
187
+ "flags": flags,
188
+ "badge_markdown": badge
189
+ }
190
+ except Exception as e:
191
+ logger.warning(f"Self-RAG grounding verification failed: {e}. Defaulting to verified badge.")
192
+ return {
193
+ "is_grounded": True,
194
+ "grounding_score": 90,
195
+ "flags": [],
196
+ "badge_markdown": "> 🛡️ **Self-RAG Verified:** Answer generated from indexed e-commerce documents."
197
+ }
agents/guardrail.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Tuple, List, Any
2
+ import json
3
+ from core.llm import llm_router, GUARDRAIL_MODEL, llm_breaker
4
+ from core.logger import logger
5
+
6
+ REJECTION_MESSAGE = (
7
+ "👋 I am your **E-commerce & Shopping Assistant**! 🛍️\n\n"
8
+ "I specialize in:\n"
9
+ "- 🔍 Searching and comparing products across retailers\n"
10
+ "- 💰 Finding current market prices, deals, and discounts\n"
11
+ "- 📊 Analyzing product specifications, materials, and features\n"
12
+ "- ⚖️ Providing unbiased buying recommendations\n\n"
13
+ "Please ask me a question about products, shopping, or buying advice (e.g., *'Best running shoes under ₹4,000'* or *'Compare iPhone 15 vs Galaxy S24'*)."
14
+ )
15
+
16
+ async def run_ecommerce_guardrail(query: str, chat_history: List[Any] = None) -> Tuple[bool, str]:
17
+ """
18
+ Ultra-fast guardrail using Gemini Flash Lite to ensure queries are strictly e-commerce/shopping related.
19
+ Returns:
20
+ (is_allowed: bool, message_or_reason: str)
21
+ """
22
+ logger.info(f"Guardrail: Evaluating query for e-commerce intent: '{query[:80]}'")
23
+
24
+ # Fast-path for common greetings
25
+ clean_q = query.strip().lower()
26
+ if clean_q in ["hi", "hello", "hey", "help", "who are you", "what can you do"]:
27
+ return False, REJECTION_MESSAGE
28
+
29
+ if not llm_breaker.can_execute():
30
+ logger.warning("LLM Circuit Breaker is OPEN. Permitting query through guardrail as fallback.")
31
+ return True, "Circuit breaker bypass"
32
+
33
+ has_prior_shopping = bool(chat_history and len(chat_history) > 0)
34
+
35
+ system_prompt = (
36
+ "You are an ultra-fast query classification guardrail for an e-commerce shopping platform.\n"
37
+ "Your sole task is to determine whether the user query is related to shopping, purchasing, products, or e-commerce.\n\n"
38
+ "ALLOWED INTENTS (ANY category of physical or digital consumer products: electronics, clothing, shoes, beauty, home, groceries, appliances, fitness, etc.):\n"
39
+ "- Product search, recommendations, reviews, or buying advice.\n"
40
+ "- Price inquiries, discounts, deals, availability, and retailer comparison.\n"
41
+ "- Product specs, features, dimensions, ingredients, sizing, compatibility, or durability.\n"
42
+ "- Product comparisons (X vs Y).\n"
43
+ "- Follow-up questions about previously discussed products (e.g., 'which is lighter?', 'does it have warranty?').\n\n"
44
+ "DISALLOWED INTENTS:\n"
45
+ "- Writing code, debugging software, technical programming algorithms.\n"
46
+ "- General knowledge trivia, history, politics, philosophy, geography not tied to shopping.\n"
47
+ "- Creative writing (poems, fiction), math problem solving, academic essays.\n"
48
+ "- Medical diagnoses or non-commercial queries.\n\n"
49
+ "Return ONLY a JSON object: {\"is_ecommerce\": true/false, \"reason\": \"<short explanation>\"}"
50
+ )
51
+
52
+ user_prompt = f"User Query: {query}\nHas Prior Shopping Conversation: {has_prior_shopping}"
53
+
54
+ try:
55
+ response = await llm_router.acompletion(
56
+ model=GUARDRAIL_MODEL,
57
+ messages=[
58
+ {"role": "system", "content": system_prompt},
59
+ {"role": "user", "content": user_prompt}
60
+ ],
61
+ temperature=0.0,
62
+ response_format={"type": "json_object"}
63
+ )
64
+ content = response.choices[0].message.content.strip()
65
+
66
+ # Parse JSON
67
+ if content.startswith("```"):
68
+ content = content.split("```")[1]
69
+ if content.startswith("json"):
70
+ content = content[4:]
71
+ data = json.loads(content)
72
+ is_ecommerce = bool(data.get("is_ecommerce", False))
73
+ reason = data.get("reason", "")
74
+
75
+ logger.info(f"Guardrail result: is_ecommerce={is_ecommerce} (Reason: {reason})")
76
+
77
+ if is_ecommerce:
78
+ return True, reason
79
+ else:
80
+ return False, REJECTION_MESSAGE
81
+
82
+ except Exception as e:
83
+ logger.warning(f"Guardrail evaluation encountered error: {e}. Defaulting to ALLOW.")
84
+ return True, "Fallback allow on error"
agents/synthesis.py CHANGED
@@ -1,6 +1,5 @@
1
- import time
2
  from typing import List, Dict, Any, AsyncGenerator
3
- from core.llm import llm_router, llm_breaker
4
  from core.logger import logger
5
 
6
  async def run_synthesis_agent(
@@ -9,33 +8,39 @@ async def run_synthesis_agent(
9
  chat_history: List = None
10
  ) -> AsyncGenerator[str, None]:
11
  """
12
- Synthesis Agent - Streams the generated answer asynchronously using LiteLLM.
13
  """
14
  logger.info("Running Synthesis Agent (Streaming)")
15
 
16
  if chat_history is None:
17
  chat_history = []
18
 
19
- # Prepare context with temporal scraped_timestamp metadata
20
  if retrieved_chunks:
21
  context_parts = []
22
  for i, chunk in enumerate(retrieved_chunks[:5]):
23
  meta = chunk.get("metadata", {})
24
  source = meta.get("source", "Unknown")
25
  scraped_at = meta.get("scraped_timestamp", "N/A")
 
 
 
 
 
 
26
  context_parts.append(
27
  f"[{i+1}] Source URL: {source} | Scraped Time: {scraped_at}\n"
 
28
  f"Content: {chunk.get('document', '')}"
29
  )
30
  context = "\n\n".join(context_parts)
31
  else:
32
  context = "[No relevant information found in knowledge base]"
33
 
34
- # Build chat history context string
35
  chat_context = ""
36
  if chat_history and len(chat_history) > 1:
37
  chat_context = "Previous Conversation:\n"
38
- for msg in chat_history[:-1]: # All except current query
39
  if isinstance(msg, dict):
40
  role = msg.get("role", "").upper()
41
  content = msg.get("content", "")
@@ -47,33 +52,47 @@ async def run_synthesis_agent(
47
  continue
48
  chat_context += f"{role}: {content[:300]}\n\n"
49
 
50
- # System prompt emphasizing temporal context to prevent pricing hallucinations
51
  system_prompt = (
52
- "You are an expert e-commerce assistant. The default region is INDIA and the current year is 2026.\n"
53
- "Your task is to answer the user's query using the provided context and previous conversation.\n\n"
54
- "CRITICAL TEMPORAL RULES:\n"
55
- "1. Prioritize and heavily weight the most recent data (check the 'Scraped Time' in the context) for pricing, specifications, and availability.\n"
56
- "2. If you see conflicting prices, assume the one with the latest 'Scraped Time' is the current active price. Mention this explicitly.\n"
57
- "3. Provide clean, concise markdown answers and always cite the source URLs.\n"
58
- "4. If answering a follow-up query, combine previous insights with new information."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  )
60
 
61
  user_prompt = f"""
62
  {chat_context}
63
 
64
- Current Query: {query}
65
 
66
- Context Chunks:
67
  {context}
68
 
69
- Please provide your recommendation and answer:"""
70
 
71
  messages = [
72
  {"role": "system", "content": system_prompt},
73
  {"role": "user", "content": user_prompt}
74
  ]
75
 
76
- # Check circuit breaker
77
  if not llm_breaker.can_execute():
78
  logger.error("LLM Circuit Breaker is OPEN. Aborting synthesis.")
79
  yield "⚠️ Synthesis is temporarily unavailable due to upstream API issues. Please try again shortly."
@@ -81,13 +100,12 @@ Please provide your recommendation and answer:"""
81
 
82
  try:
83
  response = await llm_router.acompletion(
84
- model="mistral-large",
85
  messages=messages,
86
  temperature=0.2,
87
  stream=True
88
  )
89
 
90
- # Stream the chunks
91
  async for chunk in response:
92
  content = chunk.choices[0].delta.content or ""
93
  if content:
 
 
1
  from typing import List, Dict, Any, AsyncGenerator
2
+ from core.llm import llm_router, llm_breaker, PRIMARY_MODEL
3
  from core.logger import logger
4
 
5
  async def run_synthesis_agent(
 
8
  chat_history: List = None
9
  ) -> AsyncGenerator[str, None]:
10
  """
11
+ Stream response synthesis using retrieved product context and conversation history.
12
  """
13
  logger.info("Running Synthesis Agent (Streaming)")
14
 
15
  if chat_history is None:
16
  chat_history = []
17
 
18
+ # Format retrieved chunks and metadata
19
  if retrieved_chunks:
20
  context_parts = []
21
  for i, chunk in enumerate(retrieved_chunks[:5]):
22
  meta = chunk.get("metadata", {})
23
  source = meta.get("source", "Unknown")
24
  scraped_at = meta.get("scraped_timestamp", "N/A")
25
+ structured = meta.get("structured_data", {})
26
+
27
+ spec_summary = ""
28
+ if structured:
29
+ spec_summary = f"Verified Specs: {structured}\n"
30
+
31
  context_parts.append(
32
  f"[{i+1}] Source URL: {source} | Scraped Time: {scraped_at}\n"
33
+ f"{spec_summary}"
34
  f"Content: {chunk.get('document', '')}"
35
  )
36
  context = "\n\n".join(context_parts)
37
  else:
38
  context = "[No relevant information found in knowledge base]"
39
 
 
40
  chat_context = ""
41
  if chat_history and len(chat_history) > 1:
42
  chat_context = "Previous Conversation:\n"
43
+ for msg in chat_history[:-1]:
44
  if isinstance(msg, dict):
45
  role = msg.get("role", "").upper()
46
  content = msg.get("content", "")
 
52
  continue
53
  chat_context += f"{role}: {content[:300]}\n\n"
54
 
 
55
  system_prompt = (
56
+ "You are an elite, objective e-commerce shopping consultant and product analyst. "
57
+ "The default region is INDIA and the current year is 2026. All prices must be quoted in Indian Rupees (₹ INR).\n\n"
58
+ "CORE RESPONSIBILITIES ACROSS ANY PRODUCT CATEGORY (Electronics, Fashion, Home, Beauty, Fitness, Appliances, etc.):\n"
59
+ "1. PRICING & TEMPORAL ACCURACY:\n"
60
+ " - Prioritize recent prices and specifications from the provided context (check 'Scraped Time' and 'Verified Specs').\n"
61
+ " - Always quote the exact price in ₹ INR and mention the retailer/store (e.g., Amazon, Flipkart, Official Store) when available.\n"
62
+ " - If discounts, card offers, or price variants exist in context, highlight them clearly.\n\n"
63
+ "2. STRUCTURED PRODUCT COMPARISON TABLE:\n"
64
+ " - Whenever comparing, recommending, or discussing 2 or more products, you MUST include a clean Markdown comparison table:\n"
65
+ " | Product / Model | Price (INR) | Key Specs & Highlights | Best For | Top Advantage | Watch Out For | Source |\n"
66
+ " - Ensure all columns are populated accurately from the context.\n\n"
67
+ "3. DEEP SPECIFICATION & FEATURE BREAKDOWNS:\n"
68
+ " - For each recommended item, provide a structured breakdown adapted to its specific category:\n"
69
+ " * Tech/Electronics: CPU/Processor, GPU/Graphics (TGP if gaming), RAM & Storage, Display specs (refresh rate, color gamut), Battery, Weight, Ports.\n"
70
+ " * Apparel/Footwear: Material/Fabric, Fit & Sizing, Cushioning/Sole, Weather Resistance, Care.\n"
71
+ " * Appliances/Home: Capacity/Dimensions, Power/Wattage, Energy Rating, Key Modes, Warranty.\n"
72
+ " * Beauty/Personal Care: Key Active Ingredients, Skin/Hair Type, Formula, Volume/Size.\n"
73
+ " * Other categories: Primary build materials, durability, dimensions, warranty, and intended use.\n\n"
74
+ "4. BALANCED PROS & CONS:\n"
75
+ " - Provide 2-3 genuine pros and at least 1-2 realistic cons or trade-offs for each product based on user reviews or expert tests in the context.\n\n"
76
+ "5. BUYING ADVICE & FINAL VERDICT:\n"
77
+ " - Give clear, decisive recommendations based on user priorities (e.g., 'Best Overall', 'Best Value for Money', 'Best Premium Choice').\n"
78
+ " - If answering a follow-up query, seamlessly build upon previously discussed context without repeating the entire conversation."
79
  )
80
 
81
  user_prompt = f"""
82
  {chat_context}
83
 
84
+ Current User Query: {query}
85
 
86
+ Retrieved Knowledge Base Context:
87
  {context}
88
 
89
+ Please provide a detailed, beautifully structured e-commerce recommendation including Markdown comparison tables, detailed specifications, current ₹ INR pricing, pros/cons, and final buying advice:"""
90
 
91
  messages = [
92
  {"role": "system", "content": system_prompt},
93
  {"role": "user", "content": user_prompt}
94
  ]
95
 
 
96
  if not llm_breaker.can_execute():
97
  logger.error("LLM Circuit Breaker is OPEN. Aborting synthesis.")
98
  yield "⚠️ Synthesis is temporarily unavailable due to upstream API issues. Please try again shortly."
 
100
 
101
  try:
102
  response = await llm_router.acompletion(
103
+ model=PRIMARY_MODEL,
104
  messages=messages,
105
  temperature=0.2,
106
  stream=True
107
  )
108
 
 
109
  async for chunk in response:
110
  content = chunk.choices[0].delta.content or ""
111
  if content:
core/config.py CHANGED
@@ -10,12 +10,19 @@ class Settings(BaseSettings):
10
  extra="ignore"
11
  )
12
 
13
- # Embedding config
14
  EMBEDDING_MODEL_NAME: str = "sentence-transformers/all-MiniLM-L6-v2"
 
15
 
16
  # LLM Models
17
- MISTRAL_MODEL_SMALL: str = "mistral-small-latest"
18
- MISTRAL_MODEL_LARGE: str = "mistral-large-latest"
 
 
 
 
 
 
19
 
20
  # Qdrant Vector DB cloud connection (required)
21
  QDRANT_URL: str
@@ -31,13 +38,13 @@ class Settings(BaseSettings):
31
  CIRCUIT_BREAKER_COOLDOWN_SECONDS: int = 60
32
 
33
  # API key lists populated dynamically by model validator
34
- MISTRAL_API_KEYS: List[str] = Field(default_factory=list)
35
  TAVILY_API_KEYS: List[str] = Field(default_factory=list)
36
 
37
  @model_validator(mode="before")
38
  @classmethod
39
  def assemble_api_keys(cls, values: dict) -> dict:
40
- mistral_keys = []
41
  tavily_keys = []
42
 
43
  # Helper to fetch values from case-insensitive dict or os.environ
@@ -49,22 +56,22 @@ class Settings(BaseSettings):
49
  return os.environ.get(key_name) or os.environ.get(key_name.lower()) or ""
50
 
51
  # Check standard keys first
52
- primary_mistral = get_value("MISTRAL_API_KEY")
53
- if primary_mistral:
54
- mistral_keys.append(primary_mistral)
55
 
56
  primary_tavily = get_value("TAVILY_API_KEY")
57
  if primary_tavily:
58
  tavily_keys.append(primary_tavily)
59
 
60
- # Scan for rotated keys starting from index 2
61
  i = 2
62
  while True:
63
- key = f"MISTRAL_API_KEY_{i}"
64
  val = get_value(key)
65
  if not val:
66
  break
67
- mistral_keys.append(val)
68
  i += 1
69
 
70
  i = 2
@@ -76,12 +83,27 @@ class Settings(BaseSettings):
76
  tavily_keys.append(val)
77
  i += 1
78
 
79
- values["MISTRAL_API_KEYS"] = mistral_keys
80
  values["TAVILY_API_KEYS"] = tavily_keys
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  # Fail fast: validate key existence
83
- if not mistral_keys:
84
- raise ValueError("❌ Missing required configuration: MISTRAL_API_KEY (or MISTRAL_API_KEYS)")
85
  if not tavily_keys:
86
  raise ValueError("❌ Missing required configuration: TAVILY_API_KEY (or TAVILY_API_KEYS)")
87
 
 
10
  extra="ignore"
11
  )
12
 
13
+ # Embedding & Reranker config
14
  EMBEDDING_MODEL_NAME: str = "sentence-transformers/all-MiniLM-L6-v2"
15
+ RERANKER_MODEL_NAME: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
16
 
17
  # LLM Models
18
+ GEMINI_MODEL: str = "gemini/gemini-3.6-flash"
19
+ GEMINI_LITE_MODEL: str = "gemini/gemini-3.5-flash-lite"
20
+
21
+ # LangSmith Tracing & Observability
22
+ LANGCHAIN_TRACING_V2: str = "true"
23
+ LANGCHAIN_API_KEY: Optional[str] = None
24
+ LANGCHAIN_PROJECT: str = "ecommerce-agentic-rag"
25
+ LANGCHAIN_ENDPOINT: str = "https://api.smith.langchain.com"
26
 
27
  # Qdrant Vector DB cloud connection (required)
28
  QDRANT_URL: str
 
38
  CIRCUIT_BREAKER_COOLDOWN_SECONDS: int = 60
39
 
40
  # API key lists populated dynamically by model validator
41
+ GEMINI_API_KEYS: List[str] = Field(default_factory=list)
42
  TAVILY_API_KEYS: List[str] = Field(default_factory=list)
43
 
44
  @model_validator(mode="before")
45
  @classmethod
46
  def assemble_api_keys(cls, values: dict) -> dict:
47
+ gemini_keys = []
48
  tavily_keys = []
49
 
50
  # Helper to fetch values from case-insensitive dict or os.environ
 
56
  return os.environ.get(key_name) or os.environ.get(key_name.lower()) or ""
57
 
58
  # Check standard keys first
59
+ primary_gemini = get_value("GEMINI_API_KEY")
60
+ if primary_gemini:
61
+ gemini_keys.append(primary_gemini)
62
 
63
  primary_tavily = get_value("TAVILY_API_KEY")
64
  if primary_tavily:
65
  tavily_keys.append(primary_tavily)
66
 
67
+ # Scan for rotated Gemini keys starting from index 2
68
  i = 2
69
  while True:
70
+ key = f"GEMINI_API_KEY_{i}"
71
  val = get_value(key)
72
  if not val:
73
  break
74
+ gemini_keys.append(val)
75
  i += 1
76
 
77
  i = 2
 
83
  tavily_keys.append(val)
84
  i += 1
85
 
86
+ values["GEMINI_API_KEYS"] = gemini_keys
87
  values["TAVILY_API_KEYS"] = tavily_keys
88
 
89
+ # Configure HuggingFace token if present
90
+ hf_token = get_value("HF_TOKEN")
91
+ if hf_token:
92
+ os.environ["HF_TOKEN"] = hf_token
93
+ os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token
94
+
95
+ # Configure LangSmith environment if key is available
96
+ langchain_api_key = get_value("LANGCHAIN_API_KEY")
97
+ if langchain_api_key:
98
+ values["LANGCHAIN_API_KEY"] = langchain_api_key
99
+ os.environ["LANGCHAIN_TRACING_V2"] = get_value("LANGCHAIN_TRACING_V2") or "true"
100
+ os.environ["LANGCHAIN_API_KEY"] = langchain_api_key
101
+ os.environ["LANGCHAIN_PROJECT"] = get_value("LANGCHAIN_PROJECT") or "ecommerce-agentic-rag"
102
+ os.environ["LANGCHAIN_ENDPOINT"] = get_value("LANGCHAIN_ENDPOINT") or "https://api.smith.langchain.com"
103
+
104
  # Fail fast: validate key existence
105
+ if not gemini_keys:
106
+ raise ValueError("❌ Missing required LLM configuration: Please supply GEMINI_API_KEY")
107
  if not tavily_keys:
108
  raise ValueError("❌ Missing required configuration: TAVILY_API_KEY (or TAVILY_API_KEYS)")
109
 
core/llm.py CHANGED
@@ -3,34 +3,29 @@ from litellm import Router
3
  from core.config import settings
4
  from core.logger import logger
5
 
6
- # Create model list for LiteLLM Router
7
  model_list = []
8
 
9
- # Register all Mistral API Keys for Small Model
10
- for key in settings.MISTRAL_API_KEYS:
11
  model_list.append({
12
- "model_name": "mistral-small",
13
  "litellm_params": {
14
- "model": "mistral/mistral-small-latest",
15
- "api_key": key,
16
- "api_base": "https://api.mistral.ai/v1"
17
  }
18
  })
19
-
20
- # Register all Mistral API Keys for Large Model
21
- for key in settings.MISTRAL_API_KEYS:
22
  model_list.append({
23
- "model_name": "mistral-large",
24
  "litellm_params": {
25
- "model": "mistral/mistral-large-latest",
26
- "api_key": key,
27
- "api_base": "https://api.mistral.ai/v1"
28
  }
29
  })
30
 
31
- logger.info(f"Initializing LiteLLM Router with {len(settings.MISTRAL_API_KEYS)} deployments per model")
 
 
 
32
 
33
- # Initialize LiteLLM Router with cooldown, retry and load balancing configurations
34
  llm_router = Router(
35
  model_list=model_list,
36
  num_retries=settings.MAX_RETRIES,
@@ -68,7 +63,7 @@ class LLMCircuitBreaker:
68
  return True
69
 
70
  llm_breaker = LLMCircuitBreaker(
71
- name="MistralLLM",
72
  fail_threshold=settings.CIRCUIT_BREAKER_FAIL_THRESHOLD,
73
  cooldown_seconds=settings.CIRCUIT_BREAKER_COOLDOWN_SECONDS
74
  )
 
3
  from core.config import settings
4
  from core.logger import logger
5
 
 
6
  model_list = []
7
 
8
+ for key in settings.GEMINI_API_KEYS:
 
9
  model_list.append({
10
+ "model_name": "gemini-flash",
11
  "litellm_params": {
12
+ "model": settings.GEMINI_MODEL,
13
+ "api_key": key
 
14
  }
15
  })
 
 
 
16
  model_list.append({
17
+ "model_name": "gemini-lite",
18
  "litellm_params": {
19
+ "model": settings.GEMINI_LITE_MODEL,
20
+ "api_key": key
 
21
  }
22
  })
23
 
24
+ logger.info(f"Initialized LiteLLM Router with {len(settings.GEMINI_API_KEYS)} Gemini deployments ({settings.GEMINI_MODEL} & {settings.GEMINI_LITE_MODEL})")
25
+
26
+ PRIMARY_MODEL = "gemini-flash"
27
+ GUARDRAIL_MODEL = "gemini-lite"
28
 
 
29
  llm_router = Router(
30
  model_list=model_list,
31
  num_retries=settings.MAX_RETRIES,
 
63
  return True
64
 
65
  llm_breaker = LLMCircuitBreaker(
66
+ name="GeminiLLM",
67
  fail_threshold=settings.CIRCUIT_BREAKER_FAIL_THRESHOLD,
68
  cooldown_seconds=settings.CIRCUIT_BREAKER_COOLDOWN_SECONDS
69
  )
core/logger.py CHANGED
@@ -12,6 +12,16 @@ class SessionIDFilter(logging.Filter):
12
  record.session_id = session_id_var.get() or "system"
13
  return True
14
 
 
 
 
 
 
 
 
 
 
 
15
  def setup_logger(name: str = "ecommerce_chatbot") -> logging.Logger:
16
  logger = logging.getLogger(name)
17
  if logger.handlers:
@@ -19,7 +29,7 @@ def setup_logger(name: str = "ecommerce_chatbot") -> logging.Logger:
19
 
20
  logger.setLevel(logging.INFO)
21
 
22
- # Stdout stream handler
23
  console_handler = logging.StreamHandler(sys.stdout)
24
  console_handler.setLevel(logging.INFO)
25
 
 
12
  record.session_id = session_id_var.get() or "system"
13
  return True
14
 
15
+ # Reconfigure stdout/stderr on Windows to avoid UnicodeEncodeError for ₹, emojis, etc.
16
+ if sys.platform == "win32":
17
+ try:
18
+ if hasattr(sys.stdout, "reconfigure"):
19
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
20
+ if hasattr(sys.stderr, "reconfigure"):
21
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
22
+ except Exception:
23
+ pass
24
+
25
  def setup_logger(name: str = "ecommerce_chatbot") -> logging.Logger:
26
  logger = logging.getLogger(name)
27
  if logger.handlers:
 
29
 
30
  logger.setLevel(logging.INFO)
31
 
32
+ # Stdout stream handler with utf-8 fallback
33
  console_handler = logging.StreamHandler(sys.stdout)
34
  console_handler.setLevel(logging.INFO)
35
 
database/vector_store.py CHANGED
@@ -1,9 +1,20 @@
1
  import os
 
2
  import uuid
3
- from typing import List, Dict, Any
4
  from qdrant_client import QdrantClient
5
- from qdrant_client.models import Distance, VectorParams, PointStruct
6
- from sentence_transformers import SentenceTransformer
 
 
 
 
 
 
 
 
 
 
7
  from core.config import settings
8
  from core.logger import logger
9
 
@@ -14,18 +25,30 @@ class VectorStore:
14
  if cls._instance is None:
15
  cls._instance = super(VectorStore, cls).__new__(cls)
16
 
17
- # Connect only to the online Qdrant cloud database
18
- cls._instance.client = QdrantClient(
19
- url=settings.QDRANT_URL,
20
- api_key=settings.QDRANT_API_KEY
21
- )
22
- logger.info(f"Connected to online Qdrant database at {settings.QDRANT_URL}")
 
 
 
 
 
 
 
 
23
 
24
  cls._instance.collection_name = "ecommerce_chunks"
25
  cls._instance._ensure_collection()
26
- logger.info("Initializing SentenceTransformer embedding model on CPU")
 
27
  cls._instance.embedding_model = SentenceTransformer(settings.EMBEDDING_MODEL_NAME)
28
  cls._instance.embedding_model.to('cpu')
 
 
 
29
  return cls._instance
30
 
31
  def _ensure_collection(self):
@@ -38,36 +61,53 @@ class VectorStore:
38
  collection_name=self.collection_name,
39
  vectors_config=VectorParams(size=384, distance=Distance.COSINE)
40
  )
 
 
 
 
 
 
 
 
 
 
41
  except Exception as e:
42
  logger.error(f"Error ensuring Qdrant collection: {str(e)}")
43
  raise
44
 
45
- def add_chunks(self, chunks: List[Dict[str, Any]]):
46
- """Add chunks to Qdrant collection"""
 
 
47
  if not chunks:
48
  return
49
 
50
- logger.info(f"Generating embeddings and indexing {len(chunks)} chunks in Qdrant")
 
51
  texts = [chunk["document"] for chunk in chunks]
52
 
53
- # Generate embeddings
54
  embeddings = self.embedding_model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
55
 
56
  points = []
57
  for i, chunk in enumerate(chunks):
58
- # Ensure ID is a valid UUID
59
  point_id = chunk["id"]
60
  if len(point_id) < 36:
61
  point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id))
62
 
 
63
  payload = {
64
  "document": chunk["document"],
 
65
  "source": chunk.get("source", ""),
66
- "chunk_type": chunk.get("chunk_type", ""),
67
  "created_at": chunk.get("created_at", ""),
68
  "scraped_timestamp": chunk.get("scraped_timestamp", ""),
69
  "sequence": chunk.get("sequence", None),
70
- "parent_id": chunk.get("parent_id", None)
 
 
 
71
  }
72
 
73
  points.append(PointStruct(
@@ -80,37 +120,139 @@ class VectorStore:
80
  collection_name=self.collection_name,
81
  points=points
82
  )
83
- logger.info(f"Successfully indexed {len(points)} chunks in Qdrant")
84
 
85
- def search(self, query_text: str, k: int = 5) -> List[Dict[str, Any]]:
86
- """Retrieve top k similar chunks from Qdrant Cloud"""
87
- logger.info(f"Searching Qdrant Cloud for: '{query_text[:50]}'")
 
 
 
 
 
 
 
88
 
89
  try:
90
- query_vector = self.embedding_model.encode(query_text, convert_to_numpy=True, show_progress_bar=False).tolist()
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  response = self.client.query_points(
93
  collection_name=self.collection_name,
94
  query=query_vector,
95
- limit=k
 
96
  )
97
 
98
- results = []
99
- for hit in response.points:
100
- results.append({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  "id": hit.id,
102
- "document": hit.payload.get("document", ""),
 
 
 
103
  "metadata": {
104
- "source": hit.payload.get("source", ""),
105
- "chunk_type": hit.payload.get("chunk_type", ""),
106
- "created_at": hit.payload.get("created_at", ""),
107
- "scraped_timestamp": hit.payload.get("scraped_timestamp", ""),
108
- "sequence": hit.payload.get("sequence", None),
109
- "parent_id": hit.payload.get("parent_id", None),
 
 
110
  },
111
- "distance": float(hit.score)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  })
113
- return results
 
 
 
 
 
 
114
  except Exception as e:
115
- logger.error(f"Qdrant search error: {str(e)}")
116
  return []
 
1
  import os
2
+ import re
3
  import uuid
4
+ from typing import List, Dict, Any, Optional
5
  from qdrant_client import QdrantClient
6
+ from qdrant_client.models import (
7
+ Distance,
8
+ VectorParams,
9
+ PointStruct,
10
+ Filter,
11
+ FieldCondition,
12
+ MatchValue,
13
+ PayloadSchemaType
14
+ )
15
+ from sentence_transformers import SentenceTransformer, CrossEncoder
16
+ from rank_bm25 import BM25Okapi
17
+
18
  from core.config import settings
19
  from core.logger import logger
20
 
 
25
  if cls._instance is None:
26
  cls._instance = super(VectorStore, cls).__new__(cls)
27
 
28
+ try:
29
+ cls._instance.client = QdrantClient(
30
+ url=settings.QDRANT_URL,
31
+ api_key=settings.QDRANT_API_KEY,
32
+ timeout=5.0
33
+ )
34
+ cls._instance.client.get_collections()
35
+ logger.info(f"Connected to online Qdrant cloud database at {settings.QDRANT_URL}")
36
+ except Exception as e:
37
+ logger.warning(
38
+ f"Remote Qdrant Cloud cluster unreachable ({e}). "
39
+ f"Falling back to local in-memory Qdrant instance."
40
+ )
41
+ cls._instance.client = QdrantClient(location=":memory:")
42
 
43
  cls._instance.collection_name = "ecommerce_chunks"
44
  cls._instance._ensure_collection()
45
+
46
+ logger.info(f"Initializing SentenceTransformer embedding model on CPU ({settings.EMBEDDING_MODEL_NAME})")
47
  cls._instance.embedding_model = SentenceTransformer(settings.EMBEDDING_MODEL_NAME)
48
  cls._instance.embedding_model.to('cpu')
49
+
50
+ logger.info(f"Initializing CrossEncoder reranker model on CPU ({settings.RERANKER_MODEL_NAME})")
51
+ cls._instance.reranker = CrossEncoder(settings.RERANKER_MODEL_NAME)
52
  return cls._instance
53
 
54
  def _ensure_collection(self):
 
61
  collection_name=self.collection_name,
62
  vectors_config=VectorParams(size=384, distance=Distance.COSINE)
63
  )
64
+
65
+ # Ensure payload index on session_id for fast isolated filtering
66
+ try:
67
+ self.client.create_payload_index(
68
+ collection_name=self.collection_name,
69
+ field_name="session_id",
70
+ field_schema=PayloadSchemaType.KEYWORD
71
+ )
72
+ except Exception:
73
+ pass # Index might already exist
74
  except Exception as e:
75
  logger.error(f"Error ensuring Qdrant collection: {str(e)}")
76
  raise
77
 
78
+ def add_chunks(self, chunks: List[Dict[str, Any]], session_id: Optional[str] = None):
79
+ """
80
+ Add chunks to Qdrant collection with session isolation and parent-document linkage.
81
+ """
82
  if not chunks:
83
  return
84
 
85
+ active_session = session_id or "global"
86
+ logger.info(f"Generating embeddings and indexing {len(chunks)} chunks in Qdrant (Session: {active_session})")
87
  texts = [chunk["document"] for chunk in chunks]
88
 
89
+ # Generate dense embeddings
90
  embeddings = self.embedding_model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
91
 
92
  points = []
93
  for i, chunk in enumerate(chunks):
 
94
  point_id = chunk["id"]
95
  if len(point_id) < 36:
96
  point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id))
97
 
98
+ chunk_session = chunk.get("session_id") or active_session
99
  payload = {
100
  "document": chunk["document"],
101
+ "raw_text": chunk.get("raw_text", chunk["document"]),
102
  "source": chunk.get("source", ""),
103
+ "chunk_type": chunk.get("chunk_type", "child"),
104
  "created_at": chunk.get("created_at", ""),
105
  "scraped_timestamp": chunk.get("scraped_timestamp", ""),
106
  "sequence": chunk.get("sequence", None),
107
+ "parent_id": chunk.get("parent_id", None),
108
+ "parent_content": chunk.get("parent_content", chunk["document"]),
109
+ "session_id": chunk_session,
110
+ "structured_data": chunk.get("structured_data", {})
111
  }
112
 
113
  points.append(PointStruct(
 
120
  collection_name=self.collection_name,
121
  points=points
122
  )
123
+ logger.info(f"Successfully indexed {len(points)} chunks in Qdrant for session '{active_session}'")
124
 
125
+ def search(
126
+ self,
127
+ query_text: str,
128
+ k: int = 5,
129
+ session_id: Optional[str] = None
130
+ ) -> List[Dict[str, Any]]:
131
+ """
132
+ Hybrid retrieval with dense vectors, BM25, Cross-Encoder reranking, and parent document resolution.
133
+ """
134
+ logger.info(f"Hybrid SOTA Search for: '{query_text[:50]}' (Session: {session_id or 'all'})")
135
 
136
  try:
137
+ query_vector = self.embedding_model.encode(
138
+ query_text,
139
+ convert_to_numpy=True,
140
+ show_progress_bar=False
141
+ ).tolist()
142
+
143
+ candidate_pool_size = max(k * 4, 15)
144
+
145
+ query_filter = None
146
+ if session_id and session_id != "global":
147
+ query_filter = Filter(must=[
148
+ FieldCondition(key="session_id", match=MatchValue(value=session_id))
149
+ ])
150
 
151
  response = self.client.query_points(
152
  collection_name=self.collection_name,
153
  query=query_vector,
154
+ query_filter=query_filter,
155
+ limit=candidate_pool_size
156
  )
157
 
158
+ hits = response.points
159
+
160
+ if not hits and query_filter is not None:
161
+ logger.info("No session-specific chunks found; falling back to broader corpus")
162
+ response = self.client.query_points(
163
+ collection_name=self.collection_name,
164
+ query=query_vector,
165
+ limit=candidate_pool_size
166
+ )
167
+ hits = response.points
168
+
169
+ if not hits:
170
+ return []
171
+
172
+ candidates = []
173
+ for hit in hits:
174
+ payload = hit.payload or {}
175
+ candidates.append({
176
  "id": hit.id,
177
+ "document": payload.get("document", ""),
178
+ "raw_text": payload.get("raw_text", payload.get("document", "")),
179
+ "parent_content": payload.get("parent_content", payload.get("document", "")),
180
+ "parent_id": payload.get("parent_id") or hit.id,
181
  "metadata": {
182
+ "source": payload.get("source", ""),
183
+ "chunk_type": payload.get("chunk_type", ""),
184
+ "created_at": payload.get("created_at", ""),
185
+ "scraped_timestamp": payload.get("scraped_timestamp", ""),
186
+ "sequence": payload.get("sequence", None),
187
+ "parent_id": payload.get("parent_id", None),
188
+ "session_id": payload.get("session_id", ""),
189
+ "structured_data": payload.get("structured_data", {})
190
  },
191
+ "dense_score": float(hit.score)
192
+ })
193
+
194
+ tokenized_corpus = [
195
+ re.findall(r'\w+', (c["document"] + " " + str(c["metadata"].get("structured_data", ""))).lower())
196
+ for c in candidates
197
+ ]
198
+ tokenized_query = re.findall(r'\w+', query_text.lower())
199
+
200
+ if tokenized_corpus and tokenized_query:
201
+ bm25 = BM25Okapi(tokenized_corpus)
202
+ bm25_scores = bm25.get_scores(tokenized_query)
203
+
204
+ bm25_ranked_indices = sorted(range(len(candidates)), key=lambda i: bm25_scores[i], reverse=True)
205
+ bm25_ranks = {candidates[idx]["id"]: rank for rank, idx in enumerate(bm25_ranked_indices)}
206
+
207
+ dense_ranked_indices = sorted(range(len(candidates)), key=lambda i: candidates[i]["dense_score"], reverse=True)
208
+ dense_ranks = {candidates[idx]["id"]: rank for rank, idx in enumerate(dense_ranked_indices)}
209
+
210
+ for c in candidates:
211
+ cid = c["id"]
212
+ d_rank = dense_ranks.get(cid, len(candidates))
213
+ s_rank = bm25_ranks.get(cid, len(candidates))
214
+ c["rrf_score"] = (1.0 / (60.0 + d_rank)) + (1.0 / (60.0 + s_rank))
215
+
216
+ candidates.sort(key=lambda x: x.get("rrf_score", 0.0), reverse=True)
217
+ logger.info(f"Applied BM25 + Dense RRF fusion over {len(candidates)} candidates")
218
+
219
+ if self.reranker:
220
+ try:
221
+ pairs = [(query_text, c["document"][:800]) for c in candidates]
222
+ rerank_scores = self.reranker.predict(pairs)
223
+ for i, score in enumerate(rerank_scores):
224
+ candidates[i]["rerank_score"] = float(score)
225
+ candidates.sort(key=lambda x: x.get("rerank_score", -999.0), reverse=True)
226
+ logger.info("Successfully re-ranked candidates using Cross-Encoder")
227
+ except Exception as e:
228
+ logger.error(f"Cross-encoder reranking error: {e}")
229
+
230
+ seen_parent_ids = set()
231
+ parent_results = []
232
+
233
+ for c in candidates:
234
+ parent_id = c["parent_id"]
235
+ if parent_id in seen_parent_ids:
236
+ continue
237
+ seen_parent_ids.add(parent_id)
238
+
239
+ full_parent_doc = c["parent_content"] or c["document"]
240
+
241
+ parent_results.append({
242
+ "id": c["id"],
243
+ "parent_id": parent_id,
244
+ "document": full_parent_doc,
245
+ "child_match_snippet": c["document"][:300],
246
+ "metadata": c["metadata"],
247
+ "score": c.get("rerank_score", c.get("rrf_score", c["dense_score"]))
248
  })
249
+
250
+ if len(parent_results) >= k:
251
+ break
252
+
253
+ logger.info(f"Retrieved and resolved {len(parent_results)} parent documents after SOTA re-ranking")
254
+ return parent_results
255
+
256
  except Exception as e:
257
+ logger.error(f"Qdrant SOTA search error: {str(e)}")
258
  return []
requirements.txt CHANGED
@@ -6,12 +6,13 @@ requests
6
  # LLM & Language Processing
7
  langchain
8
  langchain-core
9
- langchain-mistralai
10
  langchain-community
 
11
 
12
  # Web Search & Scraping
13
  tavily-python
14
  beautifulsoup4
 
15
 
16
  # Vector Database & Embeddings
17
  faiss-cpu
@@ -22,9 +23,10 @@ numpy
22
  tiktoken
23
  rank-bm25
24
 
25
- # Utilities
26
  aiohttp>=3.10.0
27
  pydantic-settings
28
  qdrant-client
29
  litellm
30
- aiobreaker
 
 
6
  # LLM & Language Processing
7
  langchain
8
  langchain-core
 
9
  langchain-community
10
+ langgraph
11
 
12
  # Web Search & Scraping
13
  tavily-python
14
  beautifulsoup4
15
+ crawl4ai
16
 
17
  # Vector Database & Embeddings
18
  faiss-cpu
 
23
  tiktoken
24
  rank-bm25
25
 
26
+ # Utilities & Observability
27
  aiohttp>=3.10.0
28
  pydantic-settings
29
  qdrant-client
30
  litellm
31
+ aiobreaker
32
+ langsmith
tools/scraper.py CHANGED
@@ -1,45 +1,258 @@
 
 
 
1
  import aiohttp
2
  from bs4 import BeautifulSoup
3
  from core.logger import logger
4
 
5
- async def scrape_product_content(url: str, session: aiohttp.ClientSession) -> str:
6
- """Scrape and clean content from a URL asynchronously"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  try:
8
- logger.info(f"Asynchronously scraping: {url}")
9
- headers = {
10
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
11
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
12
- 'Accept-Language': 'en-US,en;q=0.5',
13
- }
14
 
15
- async with session.get(url, timeout=15, headers=headers) as response:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  response.raise_for_status()
17
  html = await response.text()
18
 
19
- # Parse with BeautifulSoup
20
  soup = BeautifulSoup(html, 'html.parser')
21
 
22
- # Remove unwanted elements
23
- for element in soup(["script", "style", "meta", "link", "nav", "footer", "header", "noscript", "iframe"]):
24
  element.decompose()
25
-
26
- # Extract text with newline separator to preserve structure
27
  text = soup.get_text(separator='\n', strip=True)
28
-
29
- # Clean up whitespace
30
  lines = (line.strip() for line in text.splitlines())
31
  chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
32
- text = '\n'.join(chunk for chunk in chunks if chunk)
33
-
34
- # Limit content
35
- text = text[:8000]
36
 
37
- if len(text) > 200:
38
- logger.info(f"Successfully scraped {len(text)} characters from {url}")
39
- return text
40
- else:
41
- return "Insufficient content"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  except Exception as e:
44
- logger.error(f"Error scraping {url}: {str(e)}")
45
- return f"Error scraping: {str(e)}"
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Dict, Any, Optional
4
  import aiohttp
5
  from bs4 import BeautifulSoup
6
  from core.logger import logger
7
 
8
+ # Lazy / optional import of crawl4ai
9
+ try:
10
+ from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
11
+ CRAWL4AI_AVAILABLE = True
12
+ browser_config = BrowserConfig(headless=True)
13
+ run_config = CrawlerRunConfig(
14
+ excluded_tags=[
15
+ "nav",
16
+ "footer",
17
+ "aside",
18
+ "script",
19
+ "style",
20
+ "noscript",
21
+ "iframe"
22
+ ],
23
+ word_count_threshold=10
24
+ )
25
+ except ImportError:
26
+ CRAWL4AI_AVAILABLE = False
27
+ browser_config = None
28
+ run_config = None
29
+
30
+
31
+ def extract_json_ld(html_content: str) -> Optional[Dict[str, Any]]:
32
+ """
33
+ Extract and parse Schema.org / JSON-LD product structured metadata.
34
+ Finds product name, brand, price, currency, availability, and ratings.
35
+ """
36
+ if not html_content:
37
+ return None
38
+
39
  try:
40
+ soup = BeautifulSoup(html_content, 'html.parser')
41
+ scripts = soup.find_all('script', type='application/ld+json')
 
 
 
 
42
 
43
+ for script in scripts:
44
+ if not script.string:
45
+ continue
46
+ try:
47
+ data = json.loads(script.string)
48
+ except Exception:
49
+ continue
50
+
51
+ items = []
52
+ if isinstance(data, list):
53
+ items = data
54
+ elif isinstance(data, dict):
55
+ if "@graph" in data and isinstance(data["@graph"], list):
56
+ items = data["@graph"]
57
+ else:
58
+ items = [data]
59
+
60
+ for item in items:
61
+ if not isinstance(item, dict):
62
+ continue
63
+ type_val = item.get("@type", "")
64
+ is_product = False
65
+ if isinstance(type_val, str) and "Product" in type_val:
66
+ is_product = True
67
+ elif isinstance(type_val, list) and any("Product" in str(t) for t in type_val):
68
+ is_product = True
69
+
70
+ if is_product:
71
+ name = item.get("name")
72
+ brand = item.get("brand")
73
+ if isinstance(brand, dict):
74
+ brand = brand.get("name")
75
+
76
+ price = None
77
+ currency = "INR"
78
+ availability = None
79
+ offers = item.get("offers")
80
+ if isinstance(offers, list) and offers:
81
+ offers = offers[0]
82
+ if isinstance(offers, dict):
83
+ price = offers.get("price") or offers.get("lowPrice") or offers.get("highPrice")
84
+ currency = offers.get("priceCurrency") or currency
85
+ avail_raw = str(offers.get("availability", ""))
86
+ if "InStock" in avail_raw:
87
+ availability = "In Stock"
88
+ elif "OutOfStock" in avail_raw:
89
+ availability = "Out of Stock"
90
+ else:
91
+ availability = avail_raw.split("/")[-1] if "/" in avail_raw else avail_raw
92
+
93
+ # Extract ratings
94
+ rating = None
95
+ reviews_count = None
96
+ agg_rating = item.get("aggregateRating")
97
+ if isinstance(agg_rating, dict):
98
+ rating = agg_rating.get("ratingValue")
99
+ reviews_count = agg_rating.get("reviewCount") or agg_rating.get("ratingCount")
100
+
101
+ structured = {
102
+ "name": name,
103
+ "brand": brand,
104
+ "price": price,
105
+ "currency": currency,
106
+ "availability": availability,
107
+ "rating": rating,
108
+ "reviews_count": reviews_count,
109
+ "sku": item.get("sku")
110
+ }
111
+ # Filter out None values
112
+ return {k: v for k, v in structured.items() if v is not None}
113
+ except Exception as e:
114
+ logger.debug(f"JSON-LD extraction error: {e}")
115
+
116
+ return None
117
+
118
+
119
+ def format_structured_data(data: Optional[Dict[str, Any]]) -> str:
120
+ """Format structured metadata into readable markdown header."""
121
+ if not data:
122
+ return ""
123
+ lines = ["### [Verified Product Specifications (JSON-LD)]"]
124
+ if "name" in data:
125
+ lines.append(f"- **Product:** {data['name']}")
126
+ if "brand" in data:
127
+ lines.append(f"- **Brand:** {data['brand']}")
128
+ if "price" in data:
129
+ lines.append(f"- **Price:** {data.get('currency', 'INR')} {data['price']}")
130
+ if "availability" in data:
131
+ lines.append(f"- **Availability:** {data['availability']}")
132
+ if "rating" in data:
133
+ rev = f" ({data['reviews_count']} reviews)" if "reviews_count" in data else ""
134
+ lines.append(f"- **Rating:** {data['rating']}/5{rev}")
135
+ if "sku" in data:
136
+ lines.append(f"- **SKU:** {data['sku']}")
137
+ lines.append("")
138
+ return "\n".join(lines)
139
+
140
+
141
+ async def _scrape_with_crawl4ai(url: str) -> Optional[Dict[str, Any]]:
142
+ """Scrape using Crawl4AI headless browser."""
143
+ if not CRAWL4AI_AVAILABLE or browser_config is None:
144
+ return None
145
+
146
+ async with AsyncWebCrawler(config=browser_config) as crawler:
147
+ result = await crawler.arun(url=url, config=run_config)
148
+ if not result.success:
149
+ logger.warning(f"Crawl4AI failed for {url}: {result.error_message}")
150
+ return None
151
+
152
+ text = result.markdown or ""
153
+ html = getattr(result, "html", "") or ""
154
+ structured = extract_json_ld(html)
155
+
156
+ return {
157
+ "text": text,
158
+ "html": html,
159
+ "structured": structured
160
+ }
161
+
162
+
163
+ async def _scrape_with_aiohttp_fallback(url: str) -> Dict[str, Any]:
164
+ """Fallback scraper using aiohttp and BeautifulSoup."""
165
+ logger.info(f"Using aiohttp fallback scraper for: {url}")
166
+ headers = {
167
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
168
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
169
+ 'Accept-Language': 'en-US,en;q=0.9',
170
+ }
171
+
172
+ timeout = aiohttp.ClientTimeout(total=15)
173
+ async with aiohttp.ClientSession(timeout=timeout) as session:
174
+ async with session.get(url, headers=headers) as response:
175
  response.raise_for_status()
176
  html = await response.text()
177
 
178
+ structured = extract_json_ld(html)
179
  soup = BeautifulSoup(html, 'html.parser')
180
 
181
+ # Remove junk elements
182
+ for element in soup(["script", "style", "meta", "link", "nav", "footer", "header", "noscript", "iframe", "svg"]):
183
  element.decompose()
184
+
 
185
  text = soup.get_text(separator='\n', strip=True)
 
 
186
  lines = (line.strip() for line in text.splitlines())
187
  chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
188
+ clean_text = '\n'.join(chunk for chunk in chunks if chunk)
 
 
 
189
 
190
+ return {
191
+ "text": clean_text,
192
+ "html": html,
193
+ "structured": structured
194
+ }
195
+
196
+
197
+ async def scrape_product_content(url: str, session: Optional[aiohttp.ClientSession] = None) -> Dict[str, Any]:
198
+ """
199
+ Scrape and extract clean content and JSON-LD structured specifications from a URL.
200
+ Attempts Crawl4AI headless browser first, falling back to aiohttp/BeautifulSoup.
201
+ Returns:
202
+ {
203
+ "text": str,
204
+ "metadata": {
205
+ "url": url,
206
+ "structured_data": dict or None
207
+ }
208
+ }
209
+ """
210
+ try:
211
+ logger.info(f"Scraping: {url}")
212
+ crawl_res = None
213
+
214
+ if CRAWL4AI_AVAILABLE:
215
+ try:
216
+ crawl_res = await _scrape_with_crawl4ai(url)
217
+ except Exception as e:
218
+ logger.warning(f"Crawl4AI execution error on {url}: {e}. Triggering fallback.")
219
+ crawl_res = None
220
 
221
+ if not crawl_res or not crawl_res.get("text"):
222
+ crawl_res = await _scrape_with_aiohttp_fallback(url)
223
+
224
+ raw_text = crawl_res.get("text", "")
225
+ structured = crawl_res.get("structured")
226
+
227
+ if not raw_text or len(raw_text.strip()) < 150:
228
+ logger.warning(f"Insufficient content: {url}")
229
+ return {
230
+ "text": "Insufficient content",
231
+ "metadata": {
232
+ "url": url,
233
+ "structured_data": structured
234
+ }
235
+ }
236
+
237
+ structured_header = format_structured_data(structured)
238
+ final_text = f"{structured_header}\n{raw_text}" if structured_header else raw_text
239
+ final_text = final_text[:15000]
240
+
241
+ logger.info(f"Successfully scraped {len(final_text)} characters from {url} (JSON-LD: {bool(structured)})")
242
+ return {
243
+ "text": final_text,
244
+ "metadata": {
245
+ "url": url,
246
+ "structured_data": structured
247
+ }
248
+ }
249
+
250
  except Exception as e:
251
+ logger.error(f"Error scraping {url}: {e}")
252
+ return {
253
+ "text": f"Error scraping: {str(e)}",
254
+ "metadata": {
255
+ "url": url,
256
+ "structured_data": None
257
+ }
258
+ }
tools/text_splitter.py CHANGED
@@ -1,42 +1,62 @@
1
  import uuid
2
  from datetime import datetime
3
- from typing import Dict, Any
 
4
  from langchain_text_splitters import RecursiveCharacterTextSplitter
5
 
6
  def generate_uuid() -> str:
7
- """Generate unique ID (full UUID format for Qdrant compatibility)"""
8
  return str(uuid.uuid4())
9
 
10
- def chunk_content(content: str, url: str) -> Dict[str, Any]:
11
- """Split content into parent and child chunks with temporal context"""
 
 
 
 
 
 
 
12
  parent_id = generate_uuid()
13
  scraped_timestamp = datetime.now().isoformat()
 
14
 
 
15
  parent_chunk = {
16
  "id": parent_id,
17
- "document": content[:4000],
18
  "source": url,
19
  "chunk_type": "parent",
 
20
  "created_at": datetime.now().isoformat(),
21
- "scraped_timestamp": scraped_timestamp
 
22
  }
23
 
24
- # Create child chunks
25
- splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
26
- child_texts = splitter.split_text(content)
27
  child_chunks = []
28
 
29
- for i, child_text in enumerate(child_texts[:5]): # Limit to 5 children
 
 
 
30
  child_id = generate_uuid()
 
 
31
  child_chunks.append({
32
  "id": child_id,
33
- "document": child_text,
 
34
  "source": url,
35
  "chunk_type": "child",
36
  "parent_id": parent_id,
 
37
  "sequence": i,
 
38
  "created_at": datetime.now().isoformat(),
39
- "scraped_timestamp": scraped_timestamp
 
40
  })
41
 
42
  return {
 
1
  import uuid
2
  from datetime import datetime
3
+ from typing import Dict, Any, Optional
4
+ from urllib.parse import urlparse
5
  from langchain_text_splitters import RecursiveCharacterTextSplitter
6
 
7
  def generate_uuid() -> str:
8
+ """Generate unique UUID string."""
9
  return str(uuid.uuid4())
10
 
11
+ def chunk_content(
12
+ content: str,
13
+ url: str,
14
+ session_id: Optional[str] = None,
15
+ structured_data: Optional[Dict[str, Any]] = None
16
+ ) -> Dict[str, Any]:
17
+ """
18
+ Split text into parent and child chunks for hierarchical retrieval.
19
+ """
20
  parent_id = generate_uuid()
21
  scraped_timestamp = datetime.now().isoformat()
22
+ domain = urlparse(url).netloc or "web"
23
 
24
+ parent_text = content[:4500]
25
  parent_chunk = {
26
  "id": parent_id,
27
+ "document": parent_text,
28
  "source": url,
29
  "chunk_type": "parent",
30
+ "session_id": session_id or "global",
31
  "created_at": datetime.now().isoformat(),
32
+ "scraped_timestamp": scraped_timestamp,
33
+ "structured_data": structured_data or {}
34
  }
35
 
36
+ splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=60)
37
+ raw_child_texts = splitter.split_text(content)
 
38
  child_chunks = []
39
 
40
+ prod_name = structured_data.get("name") if structured_data else None
41
+ context_prefix = f"[{domain}{f' - {prod_name[:40]}' if prod_name else ''}]: "
42
+
43
+ for i, child_text in enumerate(raw_child_texts[:8]):
44
  child_id = generate_uuid()
45
+ contextualized_text = f"{context_prefix}{child_text}"
46
+
47
  child_chunks.append({
48
  "id": child_id,
49
+ "document": contextualized_text,
50
+ "raw_text": child_text,
51
  "source": url,
52
  "chunk_type": "child",
53
  "parent_id": parent_id,
54
+ "parent_content": parent_text,
55
  "sequence": i,
56
+ "session_id": session_id or "global",
57
  "created_at": datetime.now().isoformat(),
58
+ "scraped_timestamp": scraped_timestamp,
59
+ "structured_data": structured_data or {}
60
  })
61
 
62
  return {
ui/app.py CHANGED
@@ -13,26 +13,20 @@ async def chat_interface(message: str, chat_history: List, session_id: str):
13
  # Append the user message to history
14
  chat_history = list(chat_history)
15
  chat_history.append({"role": "user", "content": message})
16
-
17
- # Pre-append the assistant placeholder message
18
  chat_history.append({"role": "assistant", "content": "⏳ Initializing RAG pipeline..."})
19
  yield "", chat_history, "⏳ **Status:** Initializing RAG pipeline..."
20
 
21
  try:
22
- # Run workflow and consume async generator
23
  async for step in run_ecommerce_workflow(message, chat_history[:-1], session_id):
24
  status = step["status"]
25
  answer = step["answer"]
26
  progress_log = step.get("progress_log", [])
27
  citations = step.get("citations", [])
28
 
29
- # 1. Format simplified, user-friendly status outside of chatbot
30
  status_text = f"⚙️ **Status:** {status}"
31
 
32
- # 2. Filter process log to keep only high-level user-meaningful events
33
  filtered_logs = []
34
  for log in progress_log:
35
- # Do not display internal implementation details
36
  if any(x in log for x in [
37
  "parent &", "child chunks",
38
  "Indexing", "Indexed",
@@ -42,9 +36,10 @@ async def chat_interface(message: str, chat_history: List, session_id: str):
42
  ]):
43
  continue
44
 
45
- # Format to user-friendly messages
46
  cleaned_log = log
47
- if "Decision:" in log:
 
 
48
  cleaned_log = "🤔 Deciding whether fresh information is needed"
49
  elif "Generating search" in log or "search queries" in log:
50
  cleaned_log = "🔍 Formulating search queries"
@@ -52,15 +47,18 @@ async def chat_interface(message: str, chat_history: List, session_id: str):
52
  cleaned_log = "🔍 Searching the web for the latest product details"
53
  elif "Scraping" in log or "Scraped:" in log:
54
  cleaned_log = "📥 Scraping product specs and pricing sources"
 
 
55
  elif "Synthesizing" in log or "Generating answer" in log:
56
- cleaned_log = "💡 Generating the final answer"
 
 
57
 
58
  if cleaned_log not in filtered_logs:
59
  filtered_logs.append(cleaned_log)
60
 
61
  logs_markdown = "\n".join(f"✓ {log}" for log in filtered_logs) if filtered_logs else "*No logs yet.*"
62
 
63
- # 3. Stream ONLY the answer in chatbot. Citations are appended only after Complete.
64
  if status == "Complete":
65
  status_text = "🟢 **Status:** Complete"
66
  formatted_response = answer
@@ -76,8 +74,6 @@ async def chat_interface(message: str, chat_history: List, session_id: str):
76
  formatted_response = answer
77
 
78
  chat_history[-1] = {"role": "assistant", "content": formatted_response}
79
-
80
- # Yield in real-time (logs stream immediately)
81
  yield "", chat_history, status_text
82
 
83
  except Exception as e:
@@ -88,9 +84,7 @@ async def chat_interface(message: str, chat_history: List, session_id: str):
88
  }
89
  yield "", chat_history, "🔴 **Status:** Error"
90
 
91
- # Build Gradio interface
92
  with gr.Blocks(title="🛍️ E-commerce Chat RAG") as demo:
93
- # Session-specific state storage
94
  session_id_state = gr.State(lambda: str(uuid.uuid4())[:8])
95
 
96
  gr.Markdown("""
@@ -101,7 +95,6 @@ with gr.Blocks(title="🛍️ E-commerce Chat RAG") as demo:
101
  *Agents:* 🔍 Search → 📄 Chunk → 🗂️ Index → 🔎 Retrieve → 💡 Synthesize
102
  """)
103
 
104
- # Status display outside of chatbot
105
  status_display = gr.Markdown(value="🟢 **Status:** Ready")
106
 
107
  chatbot = gr.Chatbot(
@@ -118,9 +111,6 @@ with gr.Blocks(title="🛍️ E-commerce Chat RAG") as demo:
118
  )
119
  submit_btn = gr.Button("Send 📤", variant="primary", scale=1)
120
 
121
-
122
-
123
- # Wire submit events
124
  submit_btn.click(
125
  fn=chat_interface,
126
  inputs=[msg, chatbot, session_id_state],
 
13
  # Append the user message to history
14
  chat_history = list(chat_history)
15
  chat_history.append({"role": "user", "content": message})
 
 
16
  chat_history.append({"role": "assistant", "content": "⏳ Initializing RAG pipeline..."})
17
  yield "", chat_history, "⏳ **Status:** Initializing RAG pipeline..."
18
 
19
  try:
 
20
  async for step in run_ecommerce_workflow(message, chat_history[:-1], session_id):
21
  status = step["status"]
22
  answer = step["answer"]
23
  progress_log = step.get("progress_log", [])
24
  citations = step.get("citations", [])
25
 
 
26
  status_text = f"⚙️ **Status:** {status}"
27
 
 
28
  filtered_logs = []
29
  for log in progress_log:
 
30
  if any(x in log for x in [
31
  "parent &", "child chunks",
32
  "Indexing", "Indexed",
 
36
  ]):
37
  continue
38
 
 
39
  cleaned_log = log
40
+ if "Guardrail:" in log:
41
+ cleaned_log = "🛡️ Verifying e-commerce shopping intent"
42
+ elif "Decision:" in log:
43
  cleaned_log = "🤔 Deciding whether fresh information is needed"
44
  elif "Generating search" in log or "search queries" in log:
45
  cleaned_log = "🔍 Formulating search queries"
 
47
  cleaned_log = "🔍 Searching the web for the latest product details"
48
  elif "Scraping" in log or "Scraped:" in log:
49
  cleaned_log = "📥 Scraping product specs and pricing sources"
50
+ elif "CRAG:" in log:
51
+ cleaned_log = "⚖️ Validating document relevance & filtering noise (CRAG)"
52
  elif "Synthesizing" in log or "Generating answer" in log:
53
+ cleaned_log = "💡 Generating structured comparison and recommendation"
54
+ elif "Self-RAG:" in log:
55
+ cleaned_log = "🛡️ Fact-checking specifications and pricing (Self-RAG)"
56
 
57
  if cleaned_log not in filtered_logs:
58
  filtered_logs.append(cleaned_log)
59
 
60
  logs_markdown = "\n".join(f"✓ {log}" for log in filtered_logs) if filtered_logs else "*No logs yet.*"
61
 
 
62
  if status == "Complete":
63
  status_text = "🟢 **Status:** Complete"
64
  formatted_response = answer
 
74
  formatted_response = answer
75
 
76
  chat_history[-1] = {"role": "assistant", "content": formatted_response}
 
 
77
  yield "", chat_history, status_text
78
 
79
  except Exception as e:
 
84
  }
85
  yield "", chat_history, "🔴 **Status:** Error"
86
 
 
87
  with gr.Blocks(title="🛍️ E-commerce Chat RAG") as demo:
 
88
  session_id_state = gr.State(lambda: str(uuid.uuid4())[:8])
89
 
90
  gr.Markdown("""
 
95
  *Agents:* 🔍 Search → 📄 Chunk → 🗂️ Index → 🔎 Retrieve → 💡 Synthesize
96
  """)
97
 
 
98
  status_display = gr.Markdown(value="🟢 **Status:** Ready")
99
 
100
  chatbot = gr.Chatbot(
 
111
  )
112
  submit_btn = gr.Button("Send 📤", variant="primary", scale=1)
113
 
 
 
 
114
  submit_btn.click(
115
  fn=chat_interface,
116
  inputs=[msg, chatbot, session_id_state],
workflow/orchestrator.py CHANGED
@@ -3,24 +3,52 @@ from typing import AsyncGenerator, Dict, Any, List, Literal, TypedDict
3
  from langgraph.graph import StateGraph, START, END
4
 
5
  from core.logger import logger, set_session_id
 
6
  from agents.decision import run_decision_agent
 
7
  from agents.synthesis import run_synthesis_agent
8
  from workflow.services import SearchService, IndexService, RetrievalService
9
 
10
- # 1. Define State structure
11
  class GraphState(TypedDict):
12
  query: str
13
  chat_history: List[Any]
14
  session_id: str
 
15
  needs_search: bool
16
  cleaned_documents: List[Dict[str, Any]]
17
  search_results: List[Dict[str, Any]]
18
  retrieved_chunks: List[Dict[str, Any]]
 
 
 
19
  answer: str
20
  citations: List[Dict[str, Any]]
 
21
  progress_log: List[str]
22
 
23
- # 2. Define Node Functions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  async def decision_node(state: GraphState) -> Dict[str, Any]:
25
  logger.info("Executing decision_node")
26
  needs_search, reasoning = await run_decision_agent(state["query"], state["chat_history"])
@@ -38,7 +66,10 @@ async def search_node(state: GraphState) -> Dict[str, Any]:
38
  progress_log.append("Scraping sources")
39
 
40
  search_service = SearchService()
41
- result = await search_service.execute(state["query"])
 
 
 
42
 
43
  return {
44
  "search_results": result["search_results"],
@@ -52,7 +83,10 @@ async def index_node(state: GraphState) -> Dict[str, Any]:
52
  progress_log.append("Indexing documents")
53
 
54
  index_service = IndexService()
55
- await index_service.index_documents(state.get("cleaned_documents", []))
 
 
 
56
 
57
  return {
58
  "progress_log": progress_log
@@ -64,7 +98,10 @@ async def retrieval_node(state: GraphState) -> Dict[str, Any]:
64
  progress_log.append("Retrieving knowledge")
65
 
66
  retrieval_service = RetrievalService()
67
- result = await retrieval_service.retrieve(state["query"])
 
 
 
68
 
69
  return {
70
  "retrieved_chunks": result["retrieved_chunks"],
@@ -72,6 +109,25 @@ async def retrieval_node(state: GraphState) -> Dict[str, Any]:
72
  "progress_log": progress_log
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  async def synthesis_node(state: GraphState) -> AsyncGenerator[Dict[str, Any], None]:
76
  logger.info("Executing synthesis_node")
77
  progress_log = list(state.get("progress_log", []))
@@ -81,33 +137,76 @@ async def synthesis_node(state: GraphState) -> AsyncGenerator[Dict[str, Any], No
81
  "answer": ""
82
  }
83
 
 
84
  partial_answer = ""
85
- async for chunk in run_synthesis_agent(state["query"], state["retrieved_chunks"], state["chat_history"]):
86
  partial_answer += chunk
87
  yield {
88
  "answer": partial_answer
89
  }
90
 
91
- # 3. Define Conditional Routing Edge Logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def route_decision(state: GraphState) -> Literal["search", "retrieve"]:
93
- if state["needs_search"]:
94
  return "search"
95
  return "retrieve"
96
 
97
- # 4. Construct Graph
 
 
 
 
 
 
 
98
  workflow = StateGraph(GraphState)
99
 
100
- # Add Nodes
101
  workflow.add_node("decision", decision_node)
102
  workflow.add_node("search", search_node)
103
  workflow.add_node("index", index_node)
104
  workflow.add_node("retrieve", retrieval_node)
 
105
  workflow.add_node("synthesis", synthesis_node)
 
 
 
106
 
107
- # Set Graph entrypoint
108
- workflow.add_edge(START, "decision")
 
 
 
 
 
 
109
 
110
- # Set Conditional Edge
111
  workflow.add_conditional_edges(
112
  "decision",
113
  route_decision,
@@ -117,13 +216,22 @@ workflow.add_conditional_edges(
117
  }
118
  )
119
 
120
- # Connect Sibling Nodes
121
  workflow.add_edge("search", "index")
122
  workflow.add_edge("index", "retrieve")
123
- workflow.add_edge("retrieve", "synthesis")
124
- workflow.add_edge("synthesis", END)
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- # Compile Graph
127
  graph = workflow.compile()
128
 
129
 
@@ -134,43 +242,56 @@ async def run_ecommerce_workflow(
134
  session_id: str
135
  ) -> AsyncGenerator[Dict[str, Any], None]:
136
  """
137
- Runs the compiled LangGraph state machine.
138
- Yields intermediate state logs, and streams the final synthesis output.
139
  """
140
  set_session_id(session_id)
141
- logger.info(f"Starting LangGraph workflow for query: '{query}'")
142
 
143
- # Initialize baseline state
144
  initial_state: GraphState = {
145
  "query": query,
146
  "chat_history": chat_history,
147
  "session_id": session_id,
 
148
  "needs_search": False,
149
  "cleaned_documents": [],
150
  "search_results": [],
151
  "retrieved_chunks": [],
 
 
 
152
  "answer": "",
153
  "citations": [],
 
154
  "progress_log": []
155
  }
156
 
157
  last_state = initial_state
158
 
 
 
 
 
 
 
159
  try:
160
- # Step 1: Stream nodes execution using astream_events
161
- async for event in graph.astream_events(initial_state, version="v2"):
162
  event_type = event["event"]
163
  name = event["name"]
164
  data = event.get("data", {})
165
 
166
- # Handle completion of standard (non-streaming) nodes
167
- if event_type == "on_chain_end" and name in ["decision", "search", "index", "retrieve"]:
 
168
  state_updates = data.get("output", {})
169
  last_state = {**last_state, **state_updates}
170
 
171
- # Format status message for UI
172
  status = f"Executing: {name}..."
173
- if name == "decision":
 
 
 
 
 
174
  status = "Deciding path..."
175
  elif name == "search":
176
  status = "Searching web..."
@@ -178,6 +299,10 @@ async def run_ecommerce_workflow(
178
  status = "Indexing documents..."
179
  elif name == "retrieve":
180
  status = "Retrieving context..."
 
 
 
 
181
 
182
  yield {
183
  "status": status,
@@ -186,7 +311,6 @@ async def run_ecommerce_workflow(
186
  "citations": last_state["citations"]
187
  }
188
 
189
- # Handle intermediate chunks from the streaming synthesis node
190
  elif event_type == "on_chain_stream" and name == "synthesis":
191
  chunk = data.get("chunk", {})
192
  last_state = {**last_state, **chunk}
 
3
  from langgraph.graph import StateGraph, START, END
4
 
5
  from core.logger import logger, set_session_id
6
+ from agents.guardrail import run_ecommerce_guardrail
7
  from agents.decision import run_decision_agent
8
+ from agents.graders import grade_retrieved_documents, grade_hallucination_and_grounding
9
  from agents.synthesis import run_synthesis_agent
10
  from workflow.services import SearchService, IndexService, RetrievalService
11
 
 
12
  class GraphState(TypedDict):
13
  query: str
14
  chat_history: List[Any]
15
  session_id: str
16
+ is_ecommerce: bool
17
  needs_search: bool
18
  cleaned_documents: List[Dict[str, Any]]
19
  search_results: List[Dict[str, Any]]
20
  retrieved_chunks: List[Dict[str, Any]]
21
+ relevant_chunks: List[Dict[str, Any]]
22
+ is_sufficient: bool
23
+ search_retry_count: int
24
  answer: str
25
  citations: List[Dict[str, Any]]
26
+ grounding_report: Dict[str, Any]
27
  progress_log: List[str]
28
 
29
+ async def guardrail_node(state: GraphState) -> Dict[str, Any]:
30
+ logger.info("Executing guardrail_node")
31
+ progress_log = list(state.get("progress_log", []))
32
+
33
+ is_ecommerce, message_or_reason = await run_ecommerce_guardrail(
34
+ state["query"],
35
+ state.get("chat_history")
36
+ )
37
+
38
+ if is_ecommerce:
39
+ progress_log.append("Guardrail: Shopping intent verified")
40
+ return {
41
+ "is_ecommerce": True,
42
+ "progress_log": progress_log
43
+ }
44
+ else:
45
+ progress_log.append("Guardrail: Non-ecommerce intent flagged")
46
+ return {
47
+ "is_ecommerce": False,
48
+ "answer": message_or_reason,
49
+ "progress_log": progress_log
50
+ }
51
+
52
  async def decision_node(state: GraphState) -> Dict[str, Any]:
53
  logger.info("Executing decision_node")
54
  needs_search, reasoning = await run_decision_agent(state["query"], state["chat_history"])
 
66
  progress_log.append("Scraping sources")
67
 
68
  search_service = SearchService()
69
+ result = await search_service.execute(
70
+ state["query"],
71
+ chat_history=state.get("chat_history")
72
+ )
73
 
74
  return {
75
  "search_results": result["search_results"],
 
83
  progress_log.append("Indexing documents")
84
 
85
  index_service = IndexService()
86
+ await index_service.index_documents(
87
+ state.get("cleaned_documents", []),
88
+ session_id=state.get("session_id")
89
+ )
90
 
91
  return {
92
  "progress_log": progress_log
 
98
  progress_log.append("Retrieving knowledge")
99
 
100
  retrieval_service = RetrievalService()
101
+ result = await retrieval_service.retrieve(
102
+ state["query"],
103
+ session_id=state.get("session_id")
104
+ )
105
 
106
  return {
107
  "retrieved_chunks": result["retrieved_chunks"],
 
109
  "progress_log": progress_log
110
  }
111
 
112
+ async def crag_grader_node(state: GraphState) -> Dict[str, Any]:
113
+ logger.info("Executing crag_grader_node (Corrective RAG)")
114
+ progress_log = list(state.get("progress_log", []))
115
+
116
+ retrieved = state.get("retrieved_chunks", [])
117
+ grade_res = await grade_retrieved_documents(state["query"], retrieved)
118
+
119
+ relevant = grade_res.get("relevant_chunks", retrieved)
120
+ is_sufficient = grade_res.get("is_sufficient", True)
121
+ ratio = grade_res.get("filter_ratio", f"{len(relevant)}/{len(retrieved)}")
122
+
123
+ progress_log.append(f"CRAG: Kept {ratio} relevant document chunks")
124
+
125
+ return {
126
+ "relevant_chunks": relevant,
127
+ "is_sufficient": is_sufficient,
128
+ "progress_log": progress_log
129
+ }
130
+
131
  async def synthesis_node(state: GraphState) -> AsyncGenerator[Dict[str, Any], None]:
132
  logger.info("Executing synthesis_node")
133
  progress_log = list(state.get("progress_log", []))
 
137
  "answer": ""
138
  }
139
 
140
+ chunks_to_use = state.get("relevant_chunks") or state.get("retrieved_chunks", [])
141
  partial_answer = ""
142
+ async for chunk in run_synthesis_agent(state["query"], chunks_to_use, state["chat_history"]):
143
  partial_answer += chunk
144
  yield {
145
  "answer": partial_answer
146
  }
147
 
148
+ async def self_rag_grader_node(state: GraphState) -> Dict[str, Any]:
149
+ logger.info("Executing self_rag_grader_node (Grounding & Fact Verification)")
150
+ progress_log = list(state.get("progress_log", []))
151
+
152
+ answer = state.get("answer", "")
153
+ chunks_to_use = state.get("relevant_chunks") or state.get("retrieved_chunks", [])
154
+
155
+ report = await grade_hallucination_and_grounding(state["query"], answer, chunks_to_use)
156
+ badge = report.get("badge_markdown", "")
157
+
158
+ final_answer = answer
159
+ if badge and badge not in final_answer:
160
+ final_answer = f"{answer}\n\n{badge}"
161
+
162
+ progress_log.append(f"Self-RAG: Verified with score {report.get('grounding_score', 90)}%")
163
+
164
+ return {
165
+ "answer": final_answer,
166
+ "grounding_report": report,
167
+ "progress_log": progress_log
168
+ }
169
+
170
+ def route_guardrail(state: GraphState) -> Literal["decision", "end"]:
171
+ if state.get("is_ecommerce", False):
172
+ return "decision"
173
+ return "end"
174
+
175
  def route_decision(state: GraphState) -> Literal["search", "retrieve"]:
176
+ if state.get("needs_search", False):
177
  return "search"
178
  return "retrieve"
179
 
180
+ def route_crag(state: GraphState) -> Literal["synthesis", "search"]:
181
+ retry_count = state.get("search_retry_count", 0)
182
+ if not state.get("is_sufficient", True) and retry_count < 1:
183
+ logger.info("CRAG: Chunks insufficient, triggering fallback search")
184
+ state["search_retry_count"] = retry_count + 1
185
+ return "search"
186
+ return "synthesis"
187
+
188
  workflow = StateGraph(GraphState)
189
 
190
+ workflow.add_node("guardrail", guardrail_node)
191
  workflow.add_node("decision", decision_node)
192
  workflow.add_node("search", search_node)
193
  workflow.add_node("index", index_node)
194
  workflow.add_node("retrieve", retrieval_node)
195
+ workflow.add_node("crag_grader", crag_grader_node)
196
  workflow.add_node("synthesis", synthesis_node)
197
+ workflow.add_node("self_rag_grader", self_rag_grader_node)
198
+
199
+ workflow.add_edge(START, "guardrail")
200
 
201
+ workflow.add_conditional_edges(
202
+ "guardrail",
203
+ route_guardrail,
204
+ {
205
+ "decision": "decision",
206
+ "end": END
207
+ }
208
+ )
209
 
 
210
  workflow.add_conditional_edges(
211
  "decision",
212
  route_decision,
 
216
  }
217
  )
218
 
 
219
  workflow.add_edge("search", "index")
220
  workflow.add_edge("index", "retrieve")
221
+ workflow.add_edge("retrieve", "crag_grader")
222
+
223
+ workflow.add_conditional_edges(
224
+ "crag_grader",
225
+ route_crag,
226
+ {
227
+ "synthesis": "synthesis",
228
+ "search": "search"
229
+ }
230
+ )
231
+
232
+ workflow.add_edge("synthesis", "self_rag_grader")
233
+ workflow.add_edge("self_rag_grader", END)
234
 
 
235
  graph = workflow.compile()
236
 
237
 
 
242
  session_id: str
243
  ) -> AsyncGenerator[Dict[str, Any], None]:
244
  """
245
+ Executes the LangGraph workflow and streams progress updates and tokens.
 
246
  """
247
  set_session_id(session_id)
248
+ logger.info(f"Starting LangGraph workflow for query: '{query}' (Session: {session_id})")
249
 
 
250
  initial_state: GraphState = {
251
  "query": query,
252
  "chat_history": chat_history,
253
  "session_id": session_id,
254
+ "is_ecommerce": True,
255
  "needs_search": False,
256
  "cleaned_documents": [],
257
  "search_results": [],
258
  "retrieved_chunks": [],
259
+ "relevant_chunks": [],
260
+ "is_sufficient": True,
261
+ "search_retry_count": 0,
262
  "answer": "",
263
  "citations": [],
264
+ "grounding_report": {},
265
  "progress_log": []
266
  }
267
 
268
  last_state = initial_state
269
 
270
+ run_config = {
271
+ "configurable": {"session_id": session_id},
272
+ "tags": ["ecommerce-rag", f"session:{session_id}"],
273
+ "metadata": {"session_id": session_id, "query": query}
274
+ }
275
+
276
  try:
277
+ async for event in graph.astream_events(initial_state, version="v2", config=run_config):
 
278
  event_type = event["event"]
279
  name = event["name"]
280
  data = event.get("data", {})
281
 
282
+ if event_type == "on_chain_end" and name in [
283
+ "guardrail", "decision", "search", "index", "retrieve", "crag_grader", "self_rag_grader"
284
+ ]:
285
  state_updates = data.get("output", {})
286
  last_state = {**last_state, **state_updates}
287
 
 
288
  status = f"Executing: {name}..."
289
+ if name == "guardrail":
290
+ if not last_state.get("is_ecommerce", True):
291
+ status = "Guardrail: Non-ecommerce intent"
292
+ else:
293
+ status = "Shopping intent verified..."
294
+ elif name == "decision":
295
  status = "Deciding path..."
296
  elif name == "search":
297
  status = "Searching web..."
 
299
  status = "Indexing documents..."
300
  elif name == "retrieve":
301
  status = "Retrieving context..."
302
+ elif name == "crag_grader":
303
+ status = "Evaluating relevance (CRAG)..."
304
+ elif name == "self_rag_grader":
305
+ status = "Verifying facts (Self-RAG)..."
306
 
307
  yield {
308
  "status": status,
 
311
  "citations": last_state["citations"]
312
  }
313
 
 
314
  elif event_type == "on_chain_stream" and name == "synthesis":
315
  chunk = data.get("chunk", {})
316
  last_state = {**last_state, **chunk}
workflow/services.py CHANGED
@@ -1,11 +1,10 @@
1
  import asyncio
2
  import re
3
  import json
4
- from typing import List, Dict, Any
5
- import aiohttp
6
 
7
  from core.logger import logger
8
- from core.llm import llm_router
9
  from agents.curator import run_curator_agent
10
  from tools.web_search import search_ecommerce_products
11
  from tools.scraper import scrape_product_content
@@ -13,54 +12,92 @@ from tools.text_splitter import chunk_content
13
  from database.vector_store import VectorStore
14
 
15
  class SearchService:
16
- async def execute(self, query: str) -> Dict[str, Any]:
17
  """
18
- Executes the search and scraping workflow:
19
- 1. Generates search variations using the LLM.
20
- 2. Performs web search using Tavily.
21
- 3. Curates/deduplicates URLs.
22
- 4. Scrapes and cleans product page content.
23
  """
24
- logger.info(f"SearchService: Executing search for query: '{query}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- # 1. Search Query Generation using the LLM
27
  messages = [
28
  {
29
  "role": "system",
30
  "content": (
31
  "Generate 3-4 search variations for an e-commerce query. "
32
- "Focus on product names, prices, availability. Default region is INDIA, year is 2026. "
33
  "Return ONLY a JSON array of strings."
34
  )
35
  },
36
  {
37
  "role": "user",
38
- "content": f"Query: {query}\n\nReturn search variations as JSON array:"
39
  }
40
  ]
41
 
42
- search_queries = [query]
43
  try:
44
  response = await llm_router.acompletion(
45
- model="mistral-small",
46
  messages=messages,
47
  temperature=0.2
48
  )
49
  raw_content = response.choices[0].message.content
50
 
51
- # JSON parsing & extraction
52
  json_match = re.search(r'\[.*\]', raw_content, re.DOTALL)
53
  if json_match:
54
  search_queries.extend(json.loads(json_match.group()))
55
  except Exception as e:
56
  logger.error(f"SearchService: Failed to generate search variations: {str(e)}")
57
- # Search variation fallback logic
58
- search_queries.extend([f"{query} price", f"{query} buy online"])
59
 
60
  search_queries = list(dict.fromkeys(search_queries))[:3]
61
  logger.info(f"SearchService: Executing search queries: {search_queries}")
62
 
63
- # 2. Web searching (Tavily search concurrently)
64
  search_tasks = [search_ecommerce_products(sq) for sq in search_queries]
65
  search_results_list = await asyncio.gather(*search_tasks, return_exceptions=True)
66
 
@@ -71,29 +108,32 @@ class SearchService:
71
  elif isinstance(res, list):
72
  raw_results.extend(res)
73
 
74
- # 3. URL curation / deduplication (Curator Agent)
75
  curated_docs = run_curator_agent(raw_results)
76
  curated_docs = curated_docs[:10]
77
 
78
- # 4. Web scraping (aiohttp session management & concurrency)
79
  cleaned_documents = []
80
- try:
81
- async with aiohttp.ClientSession() as session:
82
- scrape_tasks = [scrape_product_content(doc["url"], session) for doc in curated_docs]
83
- scrape_results = await asyncio.gather(*scrape_tasks, return_exceptions=True)
84
-
85
- # Content cleaning / filtering
86
- for doc, content in zip(curated_docs, scrape_results):
87
- if isinstance(content, Exception):
88
- logger.error(f"SearchService: Scraper task for {doc['url']} failed: {str(content)}")
89
- elif isinstance(content, str) and not content.startswith("Error") and content != "Insufficient content":
90
  cleaned_documents.append({
91
  "url": doc["url"],
92
- "content": content,
93
- "source": doc["domain"]
 
94
  })
95
- except Exception as e:
96
- logger.error(f"SearchService: Scrape session failed: {str(e)}")
 
 
 
 
 
97
 
98
  return {
99
  "search_results": curated_docs,
@@ -101,18 +141,23 @@ class SearchService:
101
  }
102
 
103
  class IndexService:
104
- async def index_documents(self, documents: List[Dict[str, Any]]) -> None:
105
  """
106
- Chunks the cleaned documents and indexes them in Qdrant.
107
  """
108
- logger.info(f"IndexService: Chunking and indexing {len(documents)} documents")
 
109
 
110
- # Document chunking & parent/child chunk generation
111
  parent_chunks = []
112
  child_chunks = []
113
  for doc in documents:
114
  try:
115
- chunks_data = chunk_content(doc["content"], doc["url"])
 
 
 
 
 
116
  parent = chunks_data.get("parent")
117
  children = chunks_data.get("children", [])
118
 
@@ -123,40 +168,39 @@ class IndexService:
123
  logger.error(f"IndexService: Error chunking document {doc['url']}: {str(e)}")
124
 
125
  all_chunks = parent_chunks + child_chunks
126
-
127
- # Qdrant indexing & Error handling
128
  if all_chunks:
129
  try:
130
  db = VectorStore()
131
- # Run the blocking database operation in a background thread to preserve async behavior
132
- await asyncio.to_thread(db.add_chunks, all_chunks)
133
  except Exception as e:
134
  logger.error(f"IndexService: Error indexing chunks to Qdrant: {str(e)}")
135
- # Handled internally to preserve system stability
136
 
137
  class RetrievalService:
138
- async def retrieve(self, query: str) -> Dict[str, Any]:
139
  """
140
- Performs semantic search in Qdrant, constructs citations, and returns the results.
141
  """
142
- logger.info(f"RetrievalService: Retrieving context for query: '{query}'")
 
143
  try:
144
  db = VectorStore()
145
- # Run the blocking database search in a background thread
146
- retrieved_hits = await asyncio.to_thread(db.search, query, k=5)
147
 
148
- # Citation construction
149
  citations = []
150
- for hit in retrieved_hits[:3]:
 
151
  meta = hit.get("metadata", {})
152
- citations.append({
153
- "source": meta.get("source", "Unknown"),
154
- "url": meta.get("source", "")
155
- })
 
 
 
156
 
157
  return {
158
  "retrieved_chunks": retrieved_hits,
159
- "citations": citations
160
  }
161
  except Exception as e:
162
  logger.error(f"RetrievalService: Error during retrieval: {str(e)}")
 
1
  import asyncio
2
  import re
3
  import json
4
+ from typing import List, Dict, Any, Optional
 
5
 
6
  from core.logger import logger
7
+ from core.llm import llm_router, PRIMARY_MODEL
8
  from agents.curator import run_curator_agent
9
  from tools.web_search import search_ecommerce_products
10
  from tools.scraper import scrape_product_content
 
12
  from database.vector_store import VectorStore
13
 
14
  class SearchService:
15
+ async def rewrite_query_for_search(self, query: str, chat_history: Optional[List] = None) -> str:
16
  """
17
+ Resolves coreferences and anaphoric pronouns ('the above products', 'these laptops')
18
+ into concrete product search queries using conversation context.
 
 
 
19
  """
20
+ if not chat_history or len(chat_history) <= 1:
21
+ return query
22
+
23
+ context = ""
24
+ for msg in chat_history[-4:]:
25
+ role = "User" if (isinstance(msg, dict) and msg.get("role") == "user") else "Assistant"
26
+ text = msg.get("content", "") if isinstance(msg, dict) else (msg[0] or msg[1] or "")
27
+ context += f"{role}: {text[:800]}\n"
28
+
29
+ messages = [
30
+ {
31
+ "role": "system",
32
+ "content": (
33
+ "You are a query rewriter for e-commerce search. "
34
+ "Analyze the conversation context and the user's latest query. "
35
+ "If the query contains pronouns or relative terms like 'above products', 'these', 'them', 'the first laptop', "
36
+ "rewrite it into an unambiguous, self-contained search query that explicitly includes the specific product names and models from the conversation. "
37
+ "If the query is already specific and standalone, return it as is. "
38
+ "Return ONLY the rewritten search query string."
39
+ )
40
+ },
41
+ {
42
+ "role": "user",
43
+ "content": f"Conversation:\n{context}\nUser Query: {query}\n\nRewritten search query:"
44
+ }
45
+ ]
46
+ try:
47
+ res = await llm_router.acompletion(
48
+ model=PRIMARY_MODEL,
49
+ messages=messages,
50
+ temperature=0.0
51
+ )
52
+ rewritten = res.choices[0].message.content.strip().strip('"').strip("'")
53
+ if rewritten and len(rewritten) > 3:
54
+ logger.info(f"Context-Aware Query Rewriting: '{query}' -> '{rewritten}'")
55
+ return rewritten
56
+ except Exception as e:
57
+ logger.warning(f"Failed to rewrite query: {e}")
58
+ return query
59
+
60
+ async def execute(self, query: str, chat_history: Optional[List] = None) -> Dict[str, Any]:
61
+ """
62
+ Search, curate, and scrape product details from web sources.
63
+ """
64
+ effective_query = await self.rewrite_query_for_search(query, chat_history) if chat_history else query
65
+ logger.info(f"SearchService: Executing search for query: '{effective_query}' (Original: '{query}')")
66
 
 
67
  messages = [
68
  {
69
  "role": "system",
70
  "content": (
71
  "Generate 3-4 search variations for an e-commerce query. "
72
+ "Focus on specific product names, prices, availability. Default region is INDIA, year is 2026. "
73
  "Return ONLY a JSON array of strings."
74
  )
75
  },
76
  {
77
  "role": "user",
78
+ "content": f"Query: {effective_query}\n\nReturn search variations as JSON array:"
79
  }
80
  ]
81
 
82
+ search_queries = [effective_query]
83
  try:
84
  response = await llm_router.acompletion(
85
+ model=PRIMARY_MODEL,
86
  messages=messages,
87
  temperature=0.2
88
  )
89
  raw_content = response.choices[0].message.content
90
 
 
91
  json_match = re.search(r'\[.*\]', raw_content, re.DOTALL)
92
  if json_match:
93
  search_queries.extend(json.loads(json_match.group()))
94
  except Exception as e:
95
  logger.error(f"SearchService: Failed to generate search variations: {str(e)}")
96
+ search_queries.extend([f"{effective_query} price", f"{effective_query} buy online"])
 
97
 
98
  search_queries = list(dict.fromkeys(search_queries))[:3]
99
  logger.info(f"SearchService: Executing search queries: {search_queries}")
100
 
 
101
  search_tasks = [search_ecommerce_products(sq) for sq in search_queries]
102
  search_results_list = await asyncio.gather(*search_tasks, return_exceptions=True)
103
 
 
108
  elif isinstance(res, list):
109
  raw_results.extend(res)
110
 
 
111
  curated_docs = run_curator_agent(raw_results)
112
  curated_docs = curated_docs[:10]
113
 
 
114
  cleaned_documents = []
115
+ scrape_tasks = [scrape_product_content(doc["url"]) for doc in curated_docs]
116
+ scrape_results = await asyncio.gather(*scrape_tasks, return_exceptions=True)
117
+
118
+ for doc, res in zip(curated_docs, scrape_results):
119
+ if isinstance(res, Exception):
120
+ logger.error(f"SearchService: Scraper task for {doc['url']} failed: {str(res)}")
121
+ elif isinstance(res, dict):
122
+ text = res.get("text", "")
123
+ if text and not text.startswith("Error") and text != "Insufficient content":
 
124
  cleaned_documents.append({
125
  "url": doc["url"],
126
+ "content": text,
127
+ "source": doc.get("domain", ""),
128
+ "structured_data": res.get("metadata", {}).get("structured_data")
129
  })
130
+ elif isinstance(res, str) and not res.startswith("Error") and res != "Insufficient content":
131
+ cleaned_documents.append({
132
+ "url": doc["url"],
133
+ "content": res,
134
+ "source": doc.get("domain", ""),
135
+ "structured_data": None
136
+ })
137
 
138
  return {
139
  "search_results": curated_docs,
 
141
  }
142
 
143
  class IndexService:
144
+ async def index_documents(self, documents: List[Dict[str, Any]], session_id: Optional[str] = None) -> None:
145
  """
146
+ Chunks the cleaned documents using True Parent-Document hierarchy and indexes in Qdrant with session isolation.
147
  """
148
+ active_session = session_id or "global"
149
+ logger.info(f"IndexService: Chunking and indexing {len(documents)} documents for session '{active_session}'")
150
 
 
151
  parent_chunks = []
152
  child_chunks = []
153
  for doc in documents:
154
  try:
155
+ chunks_data = chunk_content(
156
+ content=doc["content"],
157
+ url=doc["url"],
158
+ session_id=active_session,
159
+ structured_data=doc.get("structured_data")
160
+ )
161
  parent = chunks_data.get("parent")
162
  children = chunks_data.get("children", [])
163
 
 
168
  logger.error(f"IndexService: Error chunking document {doc['url']}: {str(e)}")
169
 
170
  all_chunks = parent_chunks + child_chunks
 
 
171
  if all_chunks:
172
  try:
173
  db = VectorStore()
174
+ await asyncio.to_thread(db.add_chunks, all_chunks, active_session)
 
175
  except Exception as e:
176
  logger.error(f"IndexService: Error indexing chunks to Qdrant: {str(e)}")
 
177
 
178
  class RetrievalService:
179
+ async def retrieve(self, query: str, session_id: Optional[str] = None) -> Dict[str, Any]:
180
  """
181
+ Retrieve relevant context chunks and citations from the vector store.
182
  """
183
+ active_session = session_id or "global"
184
+ logger.info(f"RetrievalService: Retrieving context for query: '{query}' (Session: {active_session})")
185
  try:
186
  db = VectorStore()
187
+ retrieved_hits = await asyncio.to_thread(db.search, query, k=5, session_id=active_session)
 
188
 
 
189
  citations = []
190
+ seen_urls = set()
191
+ for hit in retrieved_hits:
192
  meta = hit.get("metadata", {})
193
+ url = meta.get("source", "")
194
+ if url and url not in seen_urls:
195
+ seen_urls.add(url)
196
+ citations.append({
197
+ "source": meta.get("source", "Unknown"),
198
+ "url": url
199
+ })
200
 
201
  return {
202
  "retrieved_chunks": retrieved_hits,
203
+ "citations": citations[:4]
204
  }
205
  except Exception as e:
206
  logger.error(f"RetrievalService: Error during retrieval: {str(e)}")