Rxrohans/PayLens-Dev
0
1"""2chain.py — Phase 3 of PayLens3----------------------------------------4WHAT THIS FILE DOES:5 Wires the retriever (Phase 2) to an LLM (Groq/Llama3) using LangChain.6 This is the core RAG chain — the brain of PayLens.7 8INDUSTRY PATTERNS USED HERE:9 1. Prompt Templates — structured, versioned, reusable prompts10 2. Context Injection — retrieved chunks fed into prompt safely11 3. Source Citation — LLM forced to cite which document it used12 4. Hallucination Guard — LLM told to say "I don't know" vs making things up13 5. Structured Output — consistent JSON-like response every time14 6. Latency + Token Tracking — cost & performance monitoring15 7. Chain of Thought — LLM reasons step by step before answering16 17WHY THESE MATTER:18 In production fintech AI (like Pine Labs), an answer without a source19 is a liability. A hallucinated fee amount could cause real financial harm.20 Every one of these patterns exists to prevent that.21"""22"""23chain.py — Phase 3 (v2) of ChargeClarity24------------------------------------------25WHAT'S NEW IN THIS VERSION:26 Hybrid RAG + Live Web Search27 - If FAISS retrieval confidence >= 0.60: answer from KB only (fast, grounded)28 - If FAISS retrieval confidence < 0.60: trigger DuckDuckGo live search29 - Both contexts are combined and fed to LLM together30 - LLM synthesizes KB knowledge + live results into one answer31 32FREE TOOLS USED:33 - DuckDuckGoSearchRun (langchain_community): no API key, no cost, no restrictions34 - Groq llama-3.1-8b-instant: 14,400 free requests/day35 - all-MiniLM-L6-v2: local embeddings, no API needed36"""37"""38chain.py — Phase 3 (v2) of ChargeClarity39------------------------------------------40WHAT'S NEW IN THIS VERSION:41 Hybrid RAG + Live Web Search42 - If FAISS retrieval confidence >= 0.60: answer from KB only (fast, grounded)43 - If FAISS retrieval confidence < 0.60: trigger DuckDuckGo live search44 - Both contexts are combined and fed to LLM together45 - LLM synthesizes KB knowledge + live results into one answer46 47FREE TOOLS USED:48 - DuckDuckGoSearchRun (langchain_community): no API key, no cost, no restrictions49 - Groq llama-3.1-8b-instant: 14,400 free requests/day50 - all-MiniLM-L6-v2: local embeddings, no API needed51"""52 53import os54import re55import json56import time57import logging58from pathlib import Path59from typing import Dict, List, Optional60from dataclasses import dataclass, asdict61 62from dotenv import load_dotenv63from langchain_groq import ChatGroq64from langchain_core.prompts import ChatPromptTemplate65from langchain_core.output_parsers import StrOutputParser66from langchain_community.tools import DuckDuckGoSearchRun67from datetime_parser import parse_datetime_query, DateTimeParser68from exchange_rate_fetcher import get_exchange_rate69 70load_dotenv()71 72# ─────────────────────────────────────────────────────────73# LOGGING74# ─────────────────────────────────────────────────────────75LOG_DIR = Path(__file__).parent.parent / "logs"76LOG_DIR.mkdir(exist_ok=True)77 78logging.basicConfig(79 level=logging.INFO,80 format="%(asctime)s | %(levelname)s | %(message)s",81 handlers=[82 logging.FileHandler(LOG_DIR / "chain.log", encoding="utf-8"),83 logging.StreamHandler()84 ]85)86logger = logging.getLogger("PayLens.chain")87 88# DATETIME PARSER — for temporal query awareness89datetime_parser = DateTimeParser()90 91# ─────────────────────────────────────────────────────────92# OFFICIAL LINKS REGISTRY93# Every answer includes the relevant official link94# so users always have somewhere to verify.95# ─────────────────────────────────────────────────────────96OFFICIAL_LINKS = {97 "paypal": "https://www.paypal.com/in/webapps/mpp/paypal-fees",98 "stripe": "https://stripe.com/in/pricing",99 "razorpay": "https://razorpay.com/pricing/",100 "upi": "https://www.npci.org.in/what-we-do/upi/product-overview",101 "rbi": "https://www.rbi.org.in/Scripts/BS_ViewMasCirculardetails.aspx",102 "gst": "https://www.gst.gov.in",103 "tax": "https://incometaxindia.gov.in",104 "fema": "https://fema.rbi.org.in",105 "wise": "https://wise.com/in",106 "neft": "https://www.rbi.org.in/Scripts/neft.aspx",107}108 109# ─────────────────────────────────────────────────────────110# RESPONSE DATACLASS111# ─────────────────────────────────────────────────────────112@dataclass113class ChargeAnswer:114 question: str115 answer: str116 sources: List[str]117 confidence: str118 retrieved_chunks: int119 latency_ms: float120 tokens_used: Optional[int]121 fallback_used: bool122 web_search_used: bool # NEW: did we trigger live search?123 official_links: List[str] # NEW: relevant official URLs124 125 126# ─────────────────────────────────────────────────────────127# CONFIDENCE THRESHOLD128# Below this score → trigger live web search129# ─────────────────────────────────────────────────────────130DISABLE_WEB_SEARCH = os.getenv("DISABLE_WEB_SEARCH", "0") == "1"131RAG_CONFIDENCE_THRESHOLD = 0.99 if DISABLE_WEB_SEARCH else 0.60132 133# ─────────────────────────────────────────────────────────134# PROMPTS — two versions depending on context source135# ─────────────────────────────────────────────────────────136 137# Used when RAG confidence is high (KB only)138SYSTEM_PROMPT_RAG_ONLY = """You are PayLens, a friendly expert AI that helps people \139understand payment fees, currency charges, taxes, and fintech concepts in plain English.140{date_context}141 142## Your Role143Explain things simply and practically — like a knowledgeable friend, not a legal document. \144Accuracy is non-negotiable. Never guess numbers.145 146## Strict Output Rules1471. Answer ONLY from the CONTEXT below. Never invent fees or percentages.1482. Do NOT mention document names, scores, or internal labels like [DOC 1] in your answer.1493. Write in clean, plain English with short paragraphs.1504. Use bullet points (- item) for lists of 3 or more items.1515. Use **bold** for important numbers, percentages, and key terms.1526. If context is missing info, say what you DO know, then say what to check.1537. Keep answers under 200 words unless the question genuinely needs more.1548. End your answer on a new line with exactly one of: [High] [Medium] [Low]155 156## Context157{context}158"""159 160# Used when web search is triggered (combined context)161SYSTEM_PROMPT_HYBRID = """You are PayLens, a friendly expert AI that helps people \162understand payment fees, currency charges, taxes, and fintech in plain English.163 164You have access to a curated knowledge base AND fresh live web search results.165 166{date_context}167 168**FORBIDDEN PHRASES** - NEVER use these unless the exact data is in the web results:169❌ "As of [date], the exchange rate is..."170❌ "The current rate is approximately..."171❌ "Based on today's rate of..."172❌ "At the current exchange rate of..."173 174**REQUIRED BEHAVIOR** for exchange rate queries:1751. Check if web results contain a specific exchange rate number.1762. If YES → Quote it exactly: "According to [source], the rate is X"1773. If NO → Say: "I don't have today's exchange rate. Please check Google Finance, XE.com, or your bank for current rates."1784. NEVER estimate, approximate, or use general knowledge for current rates179 180**WHY THIS MATTERS:** 181Making up exchange rates could cause users financial harm. Better to admit uncertainty 182than provide incorrect numbers.183 184## CRITICAL RULES FOR CURRENT DATA185**NEVER invent, estimate, or guess current numbers.** For ANY time-sensitive data (exchange rates, \186current prices, today's fees, recent news, etc.), you MUST:1871. Check the Live Web Search Results section below first1882. Use ONLY information from those web results for current data1893. If web results don't have the data, say "I found [what you did find], but I don't have \190current [what's missing]. Please check [official source]."1914. NEVER say "approximately" or "as of [date], the rate is X" unless X comes directly from the web results192 193## Strict Output Rules1941. For historical/general info: Use knowledge base1952. For current data: Use ONLY web search results - never make up numbers1963. Do NOT mention document names, scores, or labels like [DOC 1] in your answer1974. Write in clean, plain English with short paragraphs1985. Use bullet points (- item) for lists of 3 or more items1996. Use **bold** for important numbers, percentages, and key terms2007. If uncertain about current data, explicitly say what you don't know and where to verify2018. Keep answers under 200 words unless the question genuinely needs more2029. Add a brief disclaimer for tax/legal questions: "This is general information, not professional advice."20310. End your answer on a new line with exactly one of: [High] [Medium] [Low]204 205## Knowledge Base (Historical/General Info)206{kb_context}207 208## Live Web Search Results (Current Data - USE THIS FOR TIME-SENSITIVE INFO)209{web_context}210"""211 212HUMAN_PROMPT = "Question: {question}"213 214 215# ─────────────────────────────────────────────────────────216# LLM217# ─────────────────────────────────────────────────────────218def get_llm() -> ChatGroq:219 api_key = os.getenv("GROQ_API_KEY")220 if not api_key:221 raise ValueError("GROQ_API_KEY not found. Check your .env file!")222 return ChatGroq(223 model="llama-3.1-8b-instant",224 temperature=0,225 max_tokens=1024,226 api_key=api_key,227 )228 229 230# ─────────────────────────────────────────────────────────231# WEB SEARCH HELPER232# DuckDuckGo — free, no API key, no restrictions233# ─────────────────────────────────────────────────────────234def run_web_search(query: str) -> str:235 """236 Runs web search with smart handling for exchange rate queries.237 For currency conversions, directly fetches rates instead of searching.238 """239 try:240 # Parse datetime info from query241 dt_info = parse_datetime_query(query)242 243 # DETECT CURRENCY CONVERSION QUERIES244 query_lower = query.lower()245 246 # Detect which currency the user is asking about247 currency_patterns = {248 'USD': r'\b(usd|dollar|dollars|\$|stripe|paypal)\b',249 'EUR': r'\b(eur|euro|euros|€)\b',250 'GBP': r'\b(gbp|pound|pounds|£)\b',251 }252 253 detected_currency = None254 for code, pattern in currency_patterns.items():255 if re.search(pattern, query_lower):256 detected_currency = code257 break258 259 # Check if this is a currency conversion query260 is_currency_query = (261 detected_currency and 262 any(kw in query_lower for kw in [263 'inr', 'rupee', 'convert', 'exchange', 'cash out', 264 'get', 'rate', 'conversion', 'how much'265 ])266 )267 268 # ──────────────────────────────────────────────────────269 # DIRECT RATE FETCH for currency queries270 # ──────────────────────────────────────────────────────271 if is_currency_query and detected_currency:272 logger.info(f"💱 Currency conversion detected: {detected_currency} to INR")273 logger.info(f"🌐 Fetching live exchange rate (not searching)...")274 275 rate_info = get_exchange_rate(detected_currency, "INR")276 277 if rate_info:278 # Successfully fetched rate - format it beautifully for the LLM279 current_date = dt_info['date_context'].split(':')[1].strip()280 281 rate_str = f"""LIVE EXCHANGE RATE282Retrieved: {current_date}283━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━284 285**Current Rate:** 1 {detected_currency} = {rate_info['rate']:.4f} INR286 287**Source:** {rate_info['source']}288**Verification URL:** {rate_info['url']}289 290This is the ACTUAL current exchange rate fetched directly from {rate_info['source']}.291Use this exact rate for all calculations in your response.292 293━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━294"""295 logger.info(f"✓ Rate fetched successfully: 1 {detected_currency} = {rate_info['rate']:.4f} INR from {rate_info['source']}")296 return rate_str297 else:298 # Failed to fetch rate - inform LLM explicitly299 logger.warning(f"Failed to fetch exchange rate from all sources")300 return f"""EXCHANGE RATE FETCH FAILED301━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━302 303Unable to retrieve the current {detected_currency} to INR exchange rate.304 305Tell the user to check:306- Google Finance (search '{detected_currency} to INR')307- XE.com currency converter308- Their bank's current rates309 310Do NOT estimate or guess the exchange rate.311 312━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━313"""314 315 # ──────────────────────────────────────────────────────316 # REGULAR WEB SEARCH for non-currency queries317 # ──────────────────────────────────────────────────────318 search_query = dt_info['augmented_query'] if dt_info['has_temporal'] else query319 320 if dt_info['has_temporal']:321 logger.info(f"🕐 Temporal refs detected: {dt_info['temporal_refs']}")322 logger.info(f"📝 Search query: {search_query}")323 324 search = DuckDuckGoSearchRun()325 raw_results = search.run(search_query)326 327 formatted_results = f"""WEB SEARCH RESULTS:328 329{raw_results}330"""331 332 logger.info(f"Web search completed | query_len={len(search_query)} | result_len={len(formatted_results)}")333 return formatted_results334 335 except Exception as e:336 logger.error(f"Web search/fetch failed: {e}")337 return ""338 339# ─────────────────────────────────────────────────────────340# OFFICIAL LINKS DETECTOR341# Looks for platform mentions in question → returns relevant links342# ─────────────────────────────────────────────────────────343def detect_relevant_links(question: str, answer: str) -> List[str]:344 """Returns official links for platforms mentioned in the question/answer."""345 combined = (question + " " + answer).lower()346 links = []347 for platform, url in OFFICIAL_LINKS.items():348 if platform in combined:349 links.append(url)350 # Always include RBI if India context detected351 if "india" in combined or "inr" in combined or "rupee" in combined:352 if OFFICIAL_LINKS["rbi"] not in links:353 links.append(OFFICIAL_LINKS["rbi"])354 return list(dict.fromkeys(links)) # deduplicate while preserving order355 356# ─────────────────────────────────────────────────────────357# MAIN CHAIN CLASS358# ─────────────────────────────────────────────────────────359class ChargeChain:360 """361 Hybrid RAG + Web Search chain for PayLens.362 363 Decision logic:364 top RAG score >= 0.60 → RAG only (fast, grounded, no web call)365 top RAG score < 0.60 → RAG + DuckDuckGo live search (slower, broader)366 """367 368 def __init__(self):369 logger.info("Initialising ChargeChain v2 (Hybrid)...")370 from retriever import ChargeRetriever371 self.retriever = ChargeRetriever()372 self.llm = get_llm()373 374 # RAG-only prompt (includes date_context placeholder)375 self.rag_prompt = ChatPromptTemplate.from_messages([376 ("system", SYSTEM_PROMPT_RAG_ONLY),377 ("human", HUMAN_PROMPT),378 ])379 380 # Hybrid prompt (includes date_context placeholder)381 self.hybrid_prompt = ChatPromptTemplate.from_messages([382 ("system", SYSTEM_PROMPT_HYBRID),383 ("human", HUMAN_PROMPT),384 ])385 386 self.parser = StrOutputParser()387 logger.info("ChargeChain v2 ready [OK]")388 389 def ask(self, question: str, top_k: int = 7) -> ChargeAnswer:390 """391 Full hybrid pipeline:392 question → FAISS retrieval → confidence check393 → if high: RAG only394 → if low: RAG + DuckDuckGo web search395 → LLM synthesizes → structured answer396 """397 start = time.time()398 logger.info(f"Query: {question[:100]}")399 400 # ── Step 1: FAISS Retrieval ─────────────────────────401 retrieved = self.retriever.retrieve(question, top_k=top_k)402 403 if not retrieved:404 return self._fallback_answer(question, time.time() - start)405 406 top_score = retrieved[0]["score"]407 logger.info(f"Top RAG score: {top_score:.3f} | Threshold: {RAG_CONFIDENCE_THRESHOLD}")408 409 # ── Step 2: Decide — RAG only or Hybrid ────────────410 web_search_used = False411 web_context = ""412 413 # CRITICAL: Force web search for currency/rate queries regardless of RAG score414 # These queries ALWAYS need current data, even if KB has good context415 question_lower = question.lower()416 is_currency_query = any(kw in question_lower for kw in [417 'exchange rate', 'current rate', 'today', 'conversion rate',418 'usd to inr', 'eur to inr', 'gbp to inr', 'dollar to rupee',419 'euro to rupee', 'pound to rupee', 'convert', 'cash out',420 'how much inr', 'how much rupee'421 ])422 423 if is_currency_query:424 logger.info("🔥 Currency query detected — FORCING web search (overriding RAG score)")425 web_context = run_web_search(question)426 web_search_used = True427 elif top_score < RAG_CONFIDENCE_THRESHOLD:428 logger.info("Low RAG confidence — triggering web search")429 web_context = run_web_search(question)430 web_search_used = True431 432 433 # ── Step 3: Build KB context ────────────────────────434 kb_context = self._format_context(retrieved)435 436 # ── Step 4: Inject date context and invoke correct prompt ──437 date_context = datetime_parser.get_current_date_context()438 439 if web_search_used:440 chain = self.hybrid_prompt | self.llm | self.parser441 raw = chain.invoke({442 "date_context": date_context, # ADD THIS443 "kb_context": kb_context,444 "web_context": web_context,445 "question": question446 })447 else:448 chain = self.rag_prompt | self.llm | self.parser449 raw = chain.invoke({450 "date_context": date_context, # ADD THIS451 "context": kb_context,452 "question": question453 })454 455 # ── Step 5: Parse + enrich answer ──────────────────456 latency_ms = (time.time() - start) * 1000457 official_links = detect_relevant_links(question, raw)458 answer = self._parse_answer(459 question, raw, retrieved, latency_ms,460 web_search_used, official_links, web_context461 )462 self._log_answer(answer)463 return answer464 465 def _format_context(self, chunks: List[Dict]) -> str:466 parts = []467 for i, chunk in enumerate(chunks, 1):468 parts.append(469 f"[DOC {i} | Source: {chunk['source']} | Score: {chunk['score']:.2f}]\n"470 f"{chunk['text']}"471 )472 return "\n\n".join(parts)473 474 def _parse_answer(475 self,476 question: str,477 raw: str,478 chunks: List[Dict],479 latency_ms: float,480 web_search_used: bool,481 official_links: List[str],482 web_context: str = "", 483 484 ) -> ChargeAnswer:485 fallback_phrases = [486 "don't have enough information",487 "not enough information",488 "please check the platform"489 ]490 fallback_used = any(p in raw.lower() for p in fallback_phrases)491 492 # Extract confidence493 confidence = "medium"494 last_line = raw.strip().split("\n")[-1].lower()495 for level in ["high", "medium", "low", "none"]:496 if level in last_line:497 confidence = level498 break499 500 # Lower confidence for queries asking about current rates/prices501 # if we used web search (these are time-sensitive)502 if web_search_used:503 current_data_keywords = ["exchange rate", "current", "today", "price", "rate", "how much"]504 if any(kw in question.lower() for kw in current_data_keywords):505 # Cap confidence at medium for time-sensitive data506 if confidence == "high":507 confidence = "medium"508 logger.info("Capped confidence to medium for time-sensitive query")509 510 # Extract cited sources511 cited = list(dict.fromkeys(c["source"] for c in chunks[:3]))512 if web_search_used:513 cited.append("live_web_search")514 515 # HALLUCINATION DETECTION for exchange rates516 # If query asks about rates but web results don't contain them,517 # and LLM gives specific numbers → likely hallucination518 hallucination_phrases = [519 r"exchange rate is (approximately )?[\d.]+",520 r"current rate (is|of) (approximately )?[\d.]+",521 r"as of .+, the rate is [\d.]+",522 ]523 524 is_rate_query = any(kw in question.lower() for kw in [525 "exchange rate", "conversion rate", "convert", "euro to", "usd to", "rate"526 ])527 528 if is_rate_query and web_search_used:529 # Check if web context actually has numbers530 import re as regex531 web_has_numbers = bool(regex.search(r'\d+\.\d+', web_context)) if web_context else False532 llm_gives_numbers = any(regex.search(pattern, raw.lower()) for pattern in hallucination_phrases)533 534 if llm_gives_numbers and not web_has_numbers:535 # Likely hallucination - override response536 logger.warning("⚠️ Hallucination detected - LLM gave exchange rate but web results don't have it")537 raw = (538 "I found information about payment platforms and fees, but I don't have "539 "today's current exchange rate in my search results.\n\n"540 "For the most accurate, real-time exchange rate, please check:\n"541 "- **Google Finance** (search 'EUR to INR')\n"542 "- **XE.com** - currency converter\n"543 "- **Your bank's** current rates\n\n"544 "Once you have the current rate, I can help you understand the platform fees "545 "and total costs for your transfer.\n\n[Low]"546 )547 confidence = "low"548 fallback_used = True549 550 return ChargeAnswer(551 question = question,552 answer = raw.strip(),553 sources = cited,554 confidence = confidence,555 retrieved_chunks = len(chunks),556 latency_ms = round(latency_ms, 2),557 tokens_used = None,558 fallback_used = fallback_used,559 web_search_used = web_search_used,560 official_links = official_links,561 )562 def _fallback_answer(self, question: str, elapsed: float) -> ChargeAnswer:563 return ChargeAnswer(564 question = question,565 answer = (566 "I couldn't find relevant information in my knowledge base. "567 "Please check the official fee page of the platform you're asking about."568 ),569 sources = [],570 confidence = "none",571 retrieved_chunks = 0,572 latency_ms = round(elapsed * 1000, 2),573 tokens_used = None,574 fallback_used = True,575 web_search_used = False,576 official_links = list(OFFICIAL_LINKS.values())[:3],577 )578 579 def _log_answer(self, answer: ChargeAnswer):580 log_path = LOG_DIR / "answers.jsonl"581 with open(log_path, "a", encoding="utf-8") as f:582 f.write(json.dumps(asdict(answer), ensure_ascii=False) + "\n")583 logger.info(584 f"Logged | confidence={answer.confidence} | "585 f"latency={answer.latency_ms}ms | "586 f"web_search={answer.web_search_used} | "587 f"fallback={answer.fallback_used}"588 )589 590 591# ─────────────────────────────────────────────────────────592# Quick test593# ─────────────────────────────────────────────────────────594if __name__ == "__main__":595 chain = ChargeChain()596 597 questions = [598 "Why does PayPal charge so much when I receive money from Outlier?",599 "What is Razorpay's fee for UPI payments?",600 "How does currency conversion work and why do I lose money?",601 "Do I need to pay GST on my freelance income from abroad?",602 ]603 604 for q in questions:605 print(f"\n{'='*65}")606 print(f"Q: {q}")607 print(f"{'='*65}")608 r = chain.ask(q)609 # Clean confidence tag610 clean = re.sub(r'\s*\[(High|Medium|Low|None)\]\s*$', '', r.answer, flags=re.IGNORECASE)611 print(f"\nA: {clean}")612 print(f"\n Sources : {r.sources}")613 print(f" Confidence : {r.confidence}")614 print(f" Latency : {r.latency_ms}ms")615 print(f" Web search : {r.web_search_used}")616 print(f" Links : {r.official_links}")