CoolFace
Apppublic

Rxrohans/PayLens-Dev

sourceHugging Facemitupdated 7mo agoView on Hugging Face
0likes
chain.py616 linesDownload Raw Back to src
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}")