Spaces:
Running
Running
Implement SOTA Agentic RAG: Guardrail, CRAG, Self-RAG, Parent-Document retrieval, and Gemini 3.6 Flash migration
Browse files- agents/decision.py +24 -21
- agents/graders.py +197 -0
- agents/guardrail.py +84 -0
- agents/synthesis.py +38 -20
- core/config.py +36 -14
- core/llm.py +12 -17
- core/logger.py +11 -1
- database/vector_store.py +178 -36
- requirements.txt +5 -3
- tools/scraper.py +240 -27
- tools/text_splitter.py +32 -12
- ui/app.py +8 -18
- workflow/orchestrator.py +152 -28
- workflow/services.py +101 -57
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 |
-
|
| 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:]:
|
| 23 |
if isinstance(msg, dict):
|
| 24 |
role = msg.get("role", "").upper()
|
| 25 |
-
content = msg.get("content", "")[:
|
| 26 |
else:
|
| 27 |
try:
|
| 28 |
role = "USER" if msg[0] else "ASSISTANT"
|
| 29 |
-
content = (msg[0] or msg[1] or "")[:
|
| 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
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"1. If
|
| 43 |
-
"2. If
|
| 44 |
-
"3. If
|
| 45 |
-
"4. If
|
| 46 |
-
"
|
| 47 |
)
|
| 48 |
},
|
| 49 |
{
|
| 50 |
"role": "user",
|
| 51 |
-
"content": f"{context}\n\nCurrent
|
| 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=
|
| 62 |
messages=messages,
|
| 63 |
temperature=0.0
|
| 64 |
)
|
| 65 |
decision = response.choices[0].message.content.strip().upper()
|
| 66 |
llm_breaker.record_success()
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
reasoning = f"Decision: {'Will search web' if needs_search else 'Will use previous context'}"
|
| 70 |
-
logger.info(f"Decision Agent
|
| 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 |
-
|
| 13 |
"""
|
| 14 |
logger.info("Running Synthesis Agent (Streaming)")
|
| 15 |
|
| 16 |
if chat_history is None:
|
| 17 |
chat_history = []
|
| 18 |
|
| 19 |
-
#
|
| 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]:
|
| 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
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"1.
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
)
|
| 60 |
|
| 61 |
user_prompt = f"""
|
| 62 |
{chat_context}
|
| 63 |
|
| 64 |
-
Current Query: {query}
|
| 65 |
|
| 66 |
-
|
| 67 |
{context}
|
| 68 |
|
| 69 |
-
Please provide
|
| 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=
|
| 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 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 53 |
-
if
|
| 54 |
-
|
| 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"
|
| 64 |
val = get_value(key)
|
| 65 |
if not val:
|
| 66 |
break
|
| 67 |
-
|
| 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["
|
| 80 |
values["TAVILY_API_KEYS"] = tavily_keys
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
# Fail fast: validate key existence
|
| 83 |
-
if not
|
| 84 |
-
raise ValueError("❌ Missing required configuration:
|
| 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 |
-
|
| 10 |
-
for key in settings.MISTRAL_API_KEYS:
|
| 11 |
model_list.append({
|
| 12 |
-
"model_name": "
|
| 13 |
"litellm_params": {
|
| 14 |
-
"model":
|
| 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": "
|
| 24 |
"litellm_params": {
|
| 25 |
-
"model":
|
| 26 |
-
"api_key": key
|
| 27 |
-
"api_base": "https://api.mistral.ai/v1"
|
| 28 |
}
|
| 29 |
})
|
| 30 |
|
| 31 |
-
logger.info(f"
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
cls._instance.collection_name = "ecommerce_chunks"
|
| 25 |
cls._instance._ensure_collection()
|
| 26 |
-
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
| 47 |
if not chunks:
|
| 48 |
return
|
| 49 |
|
| 50 |
-
|
|
|
|
| 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(
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
try:
|
| 90 |
-
query_vector = self.embedding_model.encode(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
response = self.client.query_points(
|
| 93 |
collection_name=self.collection_name,
|
| 94 |
query=query_vector,
|
| 95 |
-
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
"id": hit.id,
|
| 102 |
-
"document":
|
|
|
|
|
|
|
|
|
|
| 103 |
"metadata": {
|
| 104 |
-
"source":
|
| 105 |
-
"chunk_type":
|
| 106 |
-
"created_at":
|
| 107 |
-
"scraped_timestamp":
|
| 108 |
-
"sequence":
|
| 109 |
-
"parent_id":
|
|
|
|
|
|
|
| 110 |
},
|
| 111 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
})
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
try:
|
| 8 |
-
|
| 9 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
response.raise_for_status()
|
| 17 |
html = await response.text()
|
| 18 |
|
| 19 |
-
|
| 20 |
soup = BeautifulSoup(html, 'html.parser')
|
| 21 |
|
| 22 |
-
# Remove
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
# Limit content
|
| 35 |
-
text = text[:8000]
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
except Exception as e:
|
| 44 |
-
logger.error(f"Error scraping {url}: {
|
| 45 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
return str(uuid.uuid4())
|
| 9 |
|
| 10 |
-
def chunk_content(
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
parent_id = generate_uuid()
|
| 13 |
scraped_timestamp = datetime.now().isoformat()
|
|
|
|
| 14 |
|
|
|
|
| 15 |
parent_chunk = {
|
| 16 |
"id": parent_id,
|
| 17 |
-
"document":
|
| 18 |
"source": url,
|
| 19 |
"chunk_type": "parent",
|
|
|
|
| 20 |
"created_at": datetime.now().isoformat(),
|
| 21 |
-
"scraped_timestamp": scraped_timestamp
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
child_texts = splitter.split_text(content)
|
| 27 |
child_chunks = []
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
child_id = generate_uuid()
|
|
|
|
|
|
|
| 31 |
child_chunks.append({
|
| 32 |
"id": child_id,
|
| 33 |
-
"document":
|
|
|
|
| 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 "
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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"],
|
| 86 |
partial_answer += chunk
|
| 87 |
yield {
|
| 88 |
"answer": partial_answer
|
| 89 |
}
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def route_decision(state: GraphState) -> Literal["search", "retrieve"]:
|
| 93 |
-
if state
|
| 94 |
return "search"
|
| 95 |
return "retrieve"
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
workflow = StateGraph(GraphState)
|
| 99 |
|
| 100 |
-
|
| 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 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 167 |
-
|
|
|
|
| 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 == "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 17 |
"""
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
2. Performs web search using Tavily.
|
| 21 |
-
3. Curates/deduplicates URLs.
|
| 22 |
-
4. Scrapes and cleans product page content.
|
| 23 |
"""
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: {
|
| 39 |
}
|
| 40 |
]
|
| 41 |
|
| 42 |
-
search_queries = [
|
| 43 |
try:
|
| 44 |
response = await llm_router.acompletion(
|
| 45 |
-
model=
|
| 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 |
-
|
| 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 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
elif isinstance(content, str) and not content.startswith("Error") and content != "Insufficient content":
|
| 90 |
cleaned_documents.append({
|
| 91 |
"url": doc["url"],
|
| 92 |
-
"content":
|
| 93 |
-
"source": doc
|
|
|
|
| 94 |
})
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 107 |
"""
|
| 108 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 141 |
"""
|
| 142 |
-
|
|
|
|
| 143 |
try:
|
| 144 |
db = VectorStore()
|
| 145 |
-
|
| 146 |
-
retrieved_hits = await asyncio.to_thread(db.search, query, k=5)
|
| 147 |
|
| 148 |
-
# Citation construction
|
| 149 |
citations = []
|
| 150 |
-
|
|
|
|
| 151 |
meta = hit.get("metadata", {})
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 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)}")
|