Spaces:
Running
Running
| import json | |
| import logging | |
| from datetime import datetime | |
| import httpx | |
| import asyncio | |
| import re | |
| from typing import Dict, TypedDict, Any, List, Optional | |
| from pydantic import BaseModel, Field | |
| from langchain_google_genai import ChatGoogleGenerativeAI | |
| from langchain_groq import ChatGroq | |
| from langchain_core.messages import SystemMessage, HumanMessage, AIMessage | |
| from tenacity import ( | |
| retry, | |
| stop_after_attempt, | |
| wait_exponential, | |
| retry_if_exception_type, | |
| ) | |
| from app.core.config import settings | |
| from app.db.repository import db | |
| from app.db.vector_store import vector_db | |
| from app.engine.prompts import ( | |
| RAJESH_SYSTEM_PROMPT, | |
| SCAM_DETECTOR_PROMPT, | |
| CRITIC_PROMPT, | |
| INTEL_EXTRACTOR_PROMPT, | |
| RAJESH_FALLBACK_RESPONSES | |
| ) | |
| from app.engine.tools import generate_scam_report | |
| from app.models.schemas import ExtractedIntel | |
| # Setup structured logging | |
| from pythonjsonlogger import jsonlogger | |
| logHandler = logging.StreamHandler() | |
| formatter = jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s') | |
| logHandler.setFormatter(formatter) | |
| logger = logging.getLogger(__name__) | |
| logger.addHandler(logHandler) | |
| logger.setLevel(logging.INFO) | |
| class RecommendedAction(BaseModel): | |
| action: str = Field(description="e.g. BLOCK_UPI, REPORT_DOMAIN, FREEZE_ACCOUNT") | |
| target: str = Field(description="The specific entity to act upon (e.g. scammer@okaxis)") | |
| authority: str = Field(description="The governing body (e.g. NPCI, CERT-In, RBI)") | |
| priority: str = Field(description="CRITICAL, HIGH, MEDIUM") | |
| # Structured output schema for detection | |
| class DetectionResult(BaseModel): | |
| scam_detected: bool = Field(description="True if scammer is asking for sensitive info or money") | |
| high_priority: bool = Field(description="True if message contains OTP, Bank, or UPI info", default=False) | |
| scam_type: str = Field(description="One of: bank_fraud, upi_fraud, phishing, lottery, other", default="other") | |
| scammer_sentiment: int = Field(description="Frustration 1-10", default=5) | |
| agent_response: str = Field(description="Persona response following Rajesh rules") | |
| vulnerability_level: float = Field(description="0.0 to 1.0 (Target's perceived vulnerability)", default=0.5) | |
| tactic_detected: str = Field(description="e.g. KYC, Lottery, Fear", default="IDENTIFYING_TACTIC") | |
| scammer_trust_score: float = Field(description="0.0 to 1.0 (How much the scammer thinks they are winning)", default=0.5) | |
| milestone_reached: Optional[str] = Field(description="New milestone achieved", default=None) | |
| recommended_actions: List[RecommendedAction] = Field(description="Structured forensic actions", default_factory=list) | |
| estimated_loss_prevented: float = Field(description="USD estimate based on scam type", default=0.0) | |
| statutory_violations: List[str] = Field(description="Legal sections (e.g. IT Act 66D)", default_factory=list) | |
| confidence_score: float = Field(description="AI confidence in this specific turn analysis (0.0 to 1.0)", default=0.0) | |
| class CriticResult(BaseModel): | |
| scam_detected: bool | |
| reasoning: str | |
| # Structured output schema for intel extraction | |
| class IntelResult(BaseModel): | |
| upi_ids: List[str] = [] | |
| bank_details: List[str] = [] | |
| ifsc_codes: List[str] = [] | |
| emails: List[str] = [] | |
| phone_numbers: List[str] = [] | |
| # NEW: EVALUATION CRITERIA | |
| case_ids: List[str] = [] | |
| policy_numbers: List[str] = [] | |
| order_numbers: List[str] = [] | |
| suspicious_keywords: List[str] = [] | |
| crypto_wallets: List[str] = [] | |
| phishing_links: List[str] = [] | |
| physical_addresses: List[str] = [] | |
| agent_notes: Optional[str] = None | |
| intel_found: bool = False # Flag to signal if NEW intel was found in this turn | |
| extraction_confidence: float = Field(description="Confidence score for the extraction (0.0 to 1.0)", default=0.0) | |
| risk_profile: str = Field(description="Categorization of the scammer's operation", default="ANALYZING_BEHAVIOR") | |
| behavioral_fingerprint: str = Field(description="System-generated behavioral ID", default="FINGERPRINT_PENDING") | |
| evidence_breadcrumbs: List[Dict[str, str]] = Field(description="Contextual evidence for each extracted item", default_factory=list) | |
| class IntelExtractor: | |
| # --- 1. FINANCIAL IDENTIFIERS --- | |
| # Matches ANY UPI ID pattern (e.g., scammer@okaxis, support@sbi, user@bank123) | |
| # Relaxed to allow numbers/dots in the handle part (right side of @) | |
| # Removed capturing groups to ensure findall returns full strings | |
| UPI_PATTERN = r'(?<![a-zA-Z0-9])[a-zA-Z0-9.\-_]{2,50}@[a-zA-Z0-9.\-]{2,64}(?!\.[a-zA-Z])' | |
| # Matches Indian IFSC Codes (4 letters + 0 + 6 alphanumeric) | |
| # Example: SBIN0001234, HDFC0004321 | |
| IFSC_PATTERN = r'[A-Z]{4}0[A-Z0-9]{6}' | |
| # Matches Bank Account Numbers (9-18 digits) | |
| # Must be preceded by account-related keywords to reduce false positives | |
| # Added negative lookahead to prevent matching 10-12 digit mobile numbers | |
| BANK_ACC_PATTERN = r'(?i)(?:\b(?:a/c|acc|account|ac)\b(?:\s+(?:no|number|num))?[\s:.-]*)((?!(?:0|91)?[6-9]\d{9}\b)\d{9,18})\b' | |
| # Matches suspicious keywords frequently used in scams (Page 15 Scoring trigger) | |
| SUSPICIOUS_KEYWORDS = [ | |
| "LOCKED", "OTP", "VERIFY", "KYC", "PAN CARD", "AADHAAR", | |
| "SUSPENDED", "URGENT", "PENALTY", "FINE", "INCOME TAX", | |
| "POLICE", "CBI", "RBI", "BLOCKED", "EBILL", "ELECTRICITY" | |
| ] | |
| KEYWORD_PATTERN = r'(?i)\b(?:' + '|'.join(SUSPICIOUS_KEYWORDS) + r')\b' | |
| # --- 2. CONTACT INFO --- | |
| # Matches Emails (Standard RFC 5322) | |
| # Updated to ensure we don't catch UPI IDs (handled by UPI_PATTERN) | |
| # Variable-width lookbehind removed to fix re.error; filtering moved to extract_all | |
| EMAIL_PATTERN = r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}' | |
| # Matches Indian Mobile Numbers (Strict) | |
| # Catches: +91-9876543210, 98765 43210, 09876543210, 1234567890 (as digits) | |
| # Added boundaries to avoid matching inside bank accounts | |
| PHONE_IN_PATTERN = r'(?:\b(?:\+91[\-\s]?)?0?[6-9]\d{4}[\-\s]?\d{5}\b)|\b[6-9]\d{9}\b' | |
| # Matches International/Global Numbers (Fallback for US/UK/Europe) | |
| # Catches: +1-202-555-0123, +44 7911 123456 | |
| PHONE_GLOBAL_PATTERN = r'(?:\+|00)[1-9][0-9 \-\(\)]{6,20}|\b\d{10,12}\b' | |
| # --- 3. CRYPTO & LINKS --- | |
| # Matches Bitcoin Addresses (Legacy & Segwit) - Starts with 1, 3, or bc1 | |
| BTC_PATTERN = r'\b(1[a-km-zA-Z1-9]{25,34}|3[a-km-zA-Z1-9]{25,34}|bc1[a-zA-Z0-9]{39,59})\b' | |
| # Matches Ethereum/BSC/Polygon Addresses - Starts with 0x + 40 hex chars | |
| ETH_PATTERN = r'\b0x[a-fA-F0-9]{40}\b' | |
| # Matches TRON Addresses (Common in USDT scams) - Starts with T + 33 chars | |
| TRON_PATTERN = r'\bT[a-zA-Z0-9]{33}\b' | |
| # Matches URLs (http, https, or just www.) | |
| URL_PATTERN = r'(?:https?://|www\.)[a-zA-Z0-9.\-]+(?:\.[a-zA-Z]{2,})+(?:[/?][a-zA-Z0-9.\-/?=&%_]*)?|`https?://[^`]+`' | |
| # --- 4. NEW EVALUATION PATTERNS --- | |
| # Matches Case IDs (e.g., CASE-12345, SBI-12345, REF: 9876, or REF-2023-9876) | |
| # 1. Catch [Keyword] [Separators] [ID] (supports dashes like REF-2023-9876) | |
| # 2. Catch Standalone [Letters]-[Digits]-[Digits] or [Letters][Digits] | |
| CASE_ID_PATTERN = r'(?i)\b(?:reference|ref|case|ticket|complaint|docket|request|file)(?:[\s\w.:#/-]{0,20}?)[\s:]*([A-Z0-9-]{5,30})\b|\b([A-Z]{2,4}-\d{4}-\d{4,9})\b|\b([A-Z]{2,4}[0-9]{5,12})\b' | |
| # Matches Policy Numbers (8-30 alphanumeric, supports POL-2023-4567) | |
| # Relaxed separators to catch "Policy No:", "Plan ID -" | |
| POLICY_PATTERN = r'(?i)\b(?:policy|plan|scheme|insurance|proposal)(?:[\s\w.:#-]{0,20}?)[\s:]*([A-Z0-9-]{5,30})\b|\b(POL-(?:\d{4}-)?\d{4,9})\b' | |
| # Matches Order IDs (Common in Amazon/Flipkart scams) | |
| # Relaxed to catch "AWB", "Shipment", "Item No" | |
| ORDER_PATTERN = r'(?i)\b(?:order|item|tracking|shipment|awb|delivery)(?:[\s\w.:#-]{0,20}?)[\s:]*([A-Z0-9-]{5,30})\b' | |
| # --- 5. ADDRESS & LOCATION --- | |
| # Matches common Indian address structures (Flat/House No, Street, City, Pin) | |
| # This is a complex pattern to capture multi-word city names and PIN codes | |
| ADDRESS_PATTERN = r'(?i)(?:flat|house|building|plot|hn|h\.no)[\s:#.-]*([A-Z0-9\/\s,\-]+(?:floor|wing|society|apartment|apt|nagar|mark|road|st|street|lane|area|colony)[\s,\-]+[A-Z\s]{3,20}[\s,\-]+[0-9]{6})' | |
| # Simpler fallback for just "City, State, PIN" patterns | |
| CITY_PIN_PATTERN = r'(?i)([A-Z]{3,20}(?:,\s*[A-Z]{2,20})?,\s*[0-9]{6})' | |
| def pre_process(cls, text: str) -> str: | |
| """ | |
| De-obfuscates text before extraction (e.g., 'name (at) ybl' -> 'name@ybl') | |
| """ | |
| # 1. Standardize UPI obfuscation | |
| text = re.sub(r'\s*[\(\{\[]\s*at\s*[\)\}\[]\s*', '@', text, flags=re.IGNORECASE) | |
| text = re.sub(r'\s*@\s*', '@', text) | |
| # 2. Standardize Link/Email obfuscation | |
| text = re.sub(r'\s*[\(\{\[]\s*dot\s*[\)\}\[]\s*', '.', text, flags=re.IGNORECASE) | |
| text = re.sub(r'\[\.\]', '.', text) | |
| # Handle " dot " with spaces (common in spoken-style text) - careful not to break sentences | |
| # Only replace if surrounded by alphanumeric chars (e.g., "bank dot com") | |
| text = re.sub(r'(?<=[a-zA-Z0-9])\s+dot\s+(?=[a-zA-Z0-9])', '.', text, flags=re.IGNORECASE) | |
| # 3. Handle digit spacing (e.g., '9 8 7 6' -> '9876') | |
| # Only do this if we see a long string of spaced digits | |
| digit_groups = re.findall(r'(?:\d\s+){5,}\d', text) | |
| for group in digit_groups: | |
| cleaned = group.replace(" ", "") | |
| text = text.replace(group, cleaned) | |
| return text | |
| def extract_all(cls, text: str) -> Dict[str, List[str]]: | |
| """ | |
| Runs all regex patterns against the text and returns unique deduplicated results. | |
| """ | |
| text = cls.pre_process(text) | |
| intel = { | |
| "upi_ids": set(), | |
| "bank_accounts": set(), | |
| "ifsc_codes": set(), | |
| "emails": set(), | |
| "phone_numbers": set(), | |
| "crypto_wallets": set(), | |
| "links": set(), | |
| "case_ids": set(), | |
| "policy_numbers": set(), | |
| "order_numbers": set(), | |
| "physical_addresses": set(), | |
| "suspicious_keywords": set() | |
| } | |
| # 1. Extract UPI | |
| intel["upi_ids"].update(re.findall(cls.UPI_PATTERN, text)) | |
| # 2. Extract Bank Details | |
| intel["ifsc_codes"].update(re.findall(cls.IFSC_PATTERN, text)) | |
| # For bank accounts, we need to extract group 1 (the digits) from the match | |
| bank_matches = re.finditer(cls.BANK_ACC_PATTERN, text) | |
| for m in bank_matches: | |
| # If it's the named group match, group(1) exists, otherwise use group(0) for the 16-digit fallback | |
| acc = m.group(1) if m.group(1) else m.group(0) | |
| intel["bank_accounts"].add(acc) | |
| # Remove phone numbers that might have been caught as bank accounts | |
| # Use a simpler check: if it starts with 6-9 and is 10 digits, it's likely a phone | |
| for acc in list(intel["bank_accounts"]): | |
| if re.match(r'^(?:0|91)?[6-9]\d{9}$', acc): | |
| intel["bank_accounts"].remove(acc) | |
| # 3. Extract Contacts | |
| intel["emails"].update(re.findall(cls.EMAIL_PATTERN, text)) | |
| # Deduplicate: If something is identified as an Email, remove it from UPI IDs | |
| # (Since UPI pattern is very broad and might catch emails) | |
| intel["upi_ids"] = intel["upi_ids"] - intel["emails"] | |
| # Phone: Clean up spaces/dashes and normalize before storing | |
| raw_phones = re.findall(cls.PHONE_IN_PATTERN, text) | |
| # Add Global Fallback (US/UK) | |
| raw_phones.extend(re.findall(cls.PHONE_GLOBAL_PATTERN, text)) | |
| for p in raw_phones: | |
| cleaned = p.replace(" ", "").replace("-", "").replace("(", "").replace(")", "") | |
| # If it's a 10-digit number without prefix, or has +91/0, normalize to just the 10 digits for deduplication | |
| match_10 = re.search(r'([6-9]\d{9})$', cleaned) | |
| if match_10: | |
| intel["phone_numbers"].add(match_10.group(1)) | |
| else: | |
| intel["phone_numbers"].add(cleaned) | |
| # 4. Extract Crypto | |
| intel["crypto_wallets"].update(re.findall(cls.BTC_PATTERN, text)) | |
| intel["crypto_wallets"].update(re.findall(cls.ETH_PATTERN, text)) | |
| intel["crypto_wallets"].update(re.findall(cls.TRON_PATTERN, text)) | |
| # 5. Extract Links | |
| intel["links"].update(re.findall(cls.URL_PATTERN, text)) | |
| # 6. Extract Evaluation Patterns | |
| case_matches = re.findall(cls.CASE_ID_PATTERN, text) | |
| for m in case_matches: | |
| if isinstance(m, tuple): | |
| for val in m: | |
| if val: intel["case_ids"].add(val) | |
| else: | |
| intel["case_ids"].add(m) | |
| intel["policy_numbers"].update(re.findall(cls.POLICY_PATTERN, text)) | |
| intel["order_numbers"].update(re.findall(cls.ORDER_PATTERN, text)) | |
| # Secondary fallback: Sometimes LLMs output just the ID without the keyword if it was previously mentioned. | |
| # But for regex safety, we rely on the relaxed patterns above. | |
| # 7. Extract Addresses | |
| # Try complex pattern first | |
| addr_matches = re.finditer(cls.ADDRESS_PATTERN, text) | |
| for m in addr_matches: | |
| intel["physical_addresses"].add(m.group(0).strip()) | |
| # Try city-pin fallback | |
| cp_matches = re.finditer(cls.CITY_PIN_PATTERN, text) | |
| for m in cp_matches: | |
| intel["physical_addresses"].add(m.group(0).strip()) | |
| intel["suspicious_keywords"].update(re.findall(cls.KEYWORD_PATTERN, text)) | |
| return {k: list(v) for k, v in intel.items()} | |
| # --- 4. JUDGE-GRADE HEURISTICS --- | |
| STATUTORY_MAP = { | |
| "KYC": ["IT Act 66D", "IPC 420 (Cheating)", "PMLA Section 3"], | |
| "Lottery": ["Lotteries Regulation Act Section 4", "IPC 420"], | |
| "Fear": ["IPC 506 (Criminal Intimidation)", "IT Act 66D"], | |
| "Customs": ["IPC 170 (Personating Public Servant)", "IPC 420"], | |
| "Romance": ["IPC 419 (Cheating by Personation)", "IT Act 66"], | |
| "Investment": ["SEBI Act Section 12A", "IPC 420"], | |
| "IDENTIFYING_TACTIC": ["IT Act 66 (General)"] | |
| } | |
| def get_system_fingerprint(text: str, session_id: str) -> str: | |
| """Generates a system-style behavioral fingerprint hash.""" | |
| import hashlib | |
| # Combine session_id and first 10 chars of text for a consistent but unique feel | |
| raw = f"{session_id}-{text[:10]}".encode() | |
| short_hash = hashlib.md5(raw).hexdigest()[-6:].upper() | |
| return f"BFP-{short_hash}" | |
| class AgentState(TypedDict): | |
| session_id: str | |
| user_message: str | |
| history: List[Dict[str, str]] | |
| scam_detected: bool | |
| high_priority: bool | |
| scammer_sentiment: int | |
| selected_persona: str | |
| agent_response: str | |
| intel: ExtractedIntel | |
| is_returning_scammer: bool | |
| syndicate_id: Optional[str] # Match ID if linked to other sessions | |
| syndicate_match_score: float | |
| turn_count: int | |
| vulnerability_level: float | |
| scammer_trust_score: float | |
| tactic_detected: str | |
| extraction_confidence: float | |
| risk_profile: str | |
| behavioral_fingerprint: str | |
| engagement_milestones: List[str] | |
| recommended_actions: List[Dict[str, Any]] | |
| estimated_loss_prevented: float | |
| statutory_violations: List[str] | |
| confidence_score: float | |
| scam_type: str # NEW | |
| new_intel_found: bool # Emergency trigger flag | |
| engagement_duration_seconds: int # Calculated for GUVI | |
| metadata: Dict[str, Any] # Store incoming metadata for persona selection | |
| # API Key & Model Rotation Manager | |
| class RotatingLLM: | |
| def __init__(self): | |
| self.combos = [] | |
| # 1. Collect Groq Combos (Priority 1) | |
| groq_keys = settings.GROQ_API_KEYS.copy() if settings.GROQ_API_KEYS else [] | |
| if settings.GROQ_API_KEY and settings.GROQ_API_KEY not in groq_keys: | |
| groq_keys.insert(0, settings.GROQ_API_KEY) | |
| groq_models = [ | |
| "llama-3.3-70b-versatile", | |
| "llama-3.1-8b-instant", | |
| "openai/gpt-oss-120b", | |
| "openai/gpt-oss-20b", | |
| # Preview Models | |
| "meta-llama/llama-4-maverick-17b-128e-instruct", | |
| "meta-llama/llama-4-scout-17b-16e-instruct", | |
| "qwen/qwen3-32b", | |
| "moonshotai/kimi-k2-instruct-0905", | |
| "canopylabs/orpheus-v1-english" | |
| ] | |
| # Priority 1: Groq Combos (Interleaved Keys) | |
| # Strategy: Iterate through MODELS first, then KEYS. | |
| # This ensures that if Key 1 hits a rate limit on Model A, we switch to Key 2 on Model A, | |
| # rather than switching to Model B on the same (rate-limited) Key 1. | |
| for m_idx, model in enumerate(groq_models): | |
| for k_idx, key in enumerate(groq_keys): | |
| self.combos.append({ | |
| "provider": "groq", | |
| "key": key, | |
| "model": model, | |
| "key_num": k_idx + 1 | |
| }) | |
| # 2. Collect Google Combos (Priority 2) | |
| google_keys = settings.GOOGLE_API_KEYS.copy() if settings.GOOGLE_API_KEYS else [] | |
| if settings.GOOGLE_API_KEY and settings.GOOGLE_API_KEY not in google_keys: | |
| google_keys.insert(0, settings.GOOGLE_API_KEY) | |
| google_models = [ | |
| "gemini-2.0-flash", | |
| "gemini-1.5-flash", | |
| "gemini-1.5-pro" | |
| ] | |
| # Priority 2: Google Combos (Interleaved Keys) | |
| for m_idx, model in enumerate(google_models): | |
| for k_idx, key in enumerate(google_keys): | |
| self.combos.append({ | |
| "provider": "google", | |
| "key": key, | |
| "model": model, | |
| "key_num": k_idx + 1 | |
| }) | |
| if not self.combos: | |
| logger.error("🚨 NO API KEYS FOUND! System will fail.") | |
| else: | |
| logger.info(f"🗝️ Initialized with {len(self.combos)} prioritized LLM combinations (Groq first).") | |
| self.current_index = 0 | |
| self.blacklist = {} # index -> expiry_time | |
| self._init_llm() | |
| def _init_llm(self): | |
| if not self.combos: return | |
| combo = self.combos[self.current_index] | |
| provider = combo["provider"] | |
| model = combo["model"] | |
| key = combo["key"] | |
| logger.info(f"🤖 Initializing LLM: Provider={provider.upper()}, Model={model}, Key Index={combo['key_num']}") | |
| if provider == "groq": | |
| self.llm = ChatGroq( | |
| model=model, | |
| groq_api_key=key, | |
| temperature=0.7, | |
| max_retries=0 | |
| ) | |
| else: | |
| self.llm = ChatGoogleGenerativeAI( | |
| model=model, | |
| google_api_key=key, | |
| temperature=0.7, | |
| max_retries=0 | |
| ) | |
| self.structured_detector = self.llm.with_structured_output(DetectionResult) | |
| self.structured_critic = self.llm.with_structured_output(CriticResult) | |
| self.structured_extractor = self.llm.with_structured_output(IntelResult) | |
| def rotate(self, reason="Generic"): | |
| """ | |
| Rotates to the next available combination in the prioritized list. | |
| """ | |
| import time | |
| now = time.time() | |
| self.blacklist = {k: v for k, v in self.blacklist.items() if v > now} | |
| if not self.combos: return | |
| for _ in range(len(self.combos)): | |
| self.current_index = (self.current_index + 1) % len(self.combos) | |
| if self.current_index not in self.blacklist: | |
| combo = self.combos[self.current_index] | |
| logger.warning(f"🔄 ROTATION: Switching to {combo['provider'].upper()} | Model: {combo['model']} | KeyIdx: {combo['key_num']} due to {reason}") | |
| self._init_llm() | |
| return | |
| logger.error("🚨 ALL LLM COMBINATIONS BLACKLISTED! Clearing blacklist and retrying.") | |
| self.blacklist.clear() | |
| self._init_llm() | |
| async def ainvoke(self, call_type, messages): | |
| import time | |
| import re | |
| start_time = time.time() | |
| GLOBAL_TIMEOUT = 27.0 # Hard limit to return before client 30s timeout | |
| # Allow multiple retries per model if needed | |
| max_attempts = len(self.combos) * 3 | |
| for attempt in range(max_attempts): | |
| # Global Timeout Check | |
| elapsed = time.time() - start_time | |
| if elapsed > GLOBAL_TIMEOUT: | |
| logger.error(f"⏰ GLOBAL TIMEOUT ({elapsed:.2f}s): Returning fallback to prevent connection drop.") | |
| if call_type == "detector": | |
| return DetectionResult(confidence=0.0, reasoning="System overload/Timeout", is_scam=False, risk_level="low") | |
| elif call_type == "critic": | |
| return CriticResult(critique="Timeout - proceeding with caution.", score=5, adjusted_response="...") | |
| elif call_type == "extractor": | |
| return IntelResult(upi_ids=[], bank_details=[], phone_numbers=[], emails=[], phishing_links=[], case_ids=[], policy_numbers=[], order_numbers=[]) | |
| else: | |
| return "... (system busy, please retry) ..." | |
| if not self.combos: break | |
| if self.current_index in self.blacklist: | |
| if self.blacklist[self.current_index] > time.time(): | |
| self.rotate("Blacklisted Index") | |
| continue | |
| combo = self.combos[self.current_index] | |
| provider = combo["provider"] | |
| try: | |
| # Calculate remaining time for this attempt | |
| remaining_time = GLOBAL_TIMEOUT - (time.time() - start_time) | |
| if remaining_time <= 1.0: raise asyncio.TimeoutError("Global timeout imminent") | |
| logger.info(f"🚀 LLM Call ({provider.upper()}): Model={combo['model']}, Key={combo['key_num']}, Attempt={attempt+1}/{max_attempts}") | |
| # Dynamic Timeout based on Model Size | |
| # Client timeout is often 30s. We need to fail fast to retry within that window. | |
| # 70b models: Give 20s (if slow, switch to 8b) | |
| # 8b models: Give 8s (should be instant) | |
| # Preview: Give 15s | |
| current_model = combo['model'] | |
| if "70b" in current_model or "120b" in current_model: | |
| model_timeout = min(20.0, remaining_time) | |
| elif "8b" in current_model or "20b" in current_model: | |
| model_timeout = min(10.0, remaining_time) | |
| else: | |
| model_timeout = min(15.0, remaining_time) | |
| if call_type == "detector": | |
| return await asyncio.wait_for(self.structured_detector.ainvoke(messages), timeout=model_timeout) | |
| elif call_type == "critic": | |
| return await asyncio.wait_for(self.structured_critic.ainvoke(messages), timeout=model_timeout) | |
| elif call_type == "extractor": | |
| return await asyncio.wait_for(self.structured_extractor.ainvoke(messages), timeout=model_timeout) | |
| elif call_type == "raw": | |
| res = await asyncio.wait_for(self.llm.ainvoke(messages), timeout=model_timeout) | |
| return res.content | |
| except Exception as e: | |
| error_str = str(e).upper() | |
| is_rate_limit = any(keyword in error_str for keyword in ["429", "RESOURCE_EXHAUSTED", "QUOTA", "LIMIT_EXCEEDED", "RATE_LIMIT"]) | |
| is_timeout = isinstance(e, asyncio.TimeoutError) or "TIMEOUT" in error_str or "DEADLINE" in error_str | |
| if is_rate_limit: | |
| # Smart Rate Limit Handling for Groq Free Tier | |
| # If we sleep for 10-15s, we will hit the 30s global timeout easily. | |
| # INSTEAD: Rotate immediately to a different key/model! | |
| # Only sleep if we have exhausted ALL options or the wait is trivial (< 2s). | |
| wait_match = re.search(r'try again in (\d+\.?\d*)s', error_str, re.IGNORECASE) | |
| if wait_match: | |
| wait_seconds = float(wait_match.group(1)) + 0.5 | |
| # Only sleep if it's super short (e.g. < 2s) | |
| if wait_seconds < 2.0: | |
| logger.warning(f"⚠️ RATE LIMIT ({combo['model']}): Short wait ({wait_seconds:.2f}s). Sleeping...") | |
| await asyncio.sleep(wait_seconds) | |
| continue | |
| # For anything > 2s (like 10s or 15s), DO NOT SLEEP. | |
| # Just mark this specific combo as busy and move to the next one. | |
| wait_time = 10 | |
| if "10M" in error_str: wait_time = 60 | |
| self.blacklist[self.current_index] = time.time() + wait_time | |
| logger.warning(f"⚠️ RATE LIMIT ({combo['model']}): Skipping sleep. Rotating immediately. Blacklisted for {wait_time}s.") | |
| self.rotate("Rate Limit (No Sleep)") | |
| continue | |
| if is_timeout: | |
| logger.warning(f"⏳ TIMEOUT ({combo['model']}): Rotating immediately.") | |
| self.rotate("Timeout") | |
| continue | |
| if "400" in error_str or "INVALID" in error_str: | |
| logger.warning(f"❌ SCHEMA/400 ERROR ({combo['model']}): Rotating to try different model.") | |
| self.rotate("400 Error") | |
| continue | |
| logger.error(f"❌ LLM Error (Attempt {attempt+1}/{max_attempts}): {e}") | |
| if attempt == max_attempts - 1: | |
| raise e | |
| raise Exception("All available LLM combinations (Groq & Google) are currently exhausted or rate limited.") | |
| rotating_manager = RotatingLLM() | |
| async def _call_detector(messages): | |
| return await rotating_manager.ainvoke("detector", messages) | |
| async def _call_critic(messages): | |
| return await rotating_manager.ainvoke("critic", messages) | |
| async def _call_extractor(messages): | |
| return await rotating_manager.ainvoke("extractor", messages) | |
| async def load_history(state: AgentState) -> AgentState: | |
| try: | |
| # Await async DB calls | |
| history = await db.get_context(state["session_id"]) | |
| state["history"] = history | |
| state["turn_count"] = len(history) | |
| state["scam_detected"] = await db.is_scam_session(state["session_id"]) | |
| # Load previously extracted intel | |
| intel_records = await db.get_session_intel(state["session_id"]) | |
| current_intel = ExtractedIntel() | |
| for rec in intel_records: | |
| if rec["type"] == "upi": | |
| current_intel.upi_ids.append(rec["value"]) | |
| elif rec["type"] == "bank": | |
| current_intel.bank_details.append(rec["value"]) | |
| elif rec["type"] == "link": | |
| current_intel.phishing_links.append(rec["value"]) | |
| elif rec["type"] == "phone": | |
| current_intel.phone_numbers.append(rec["value"]) | |
| state["intel"] = current_intel | |
| # Calculate Duration | |
| first_msg_time = await db.get_first_message_time(state["session_id"]) | |
| if first_msg_time: | |
| state["engagement_duration_seconds"] = int((datetime.now() - first_msg_time).total_seconds()) | |
| logger.info(f"⏳ Duration Calc: First Msg {first_msg_time} -> {state['engagement_duration_seconds']}s") | |
| else: | |
| # Fallback: Estimate based on turn count if timestamp missing | |
| turn_count = state.get("turn_count", 0) | |
| state["engagement_duration_seconds"] = max(turn_count * 30, 30) # Estimate 30s per turn | |
| logger.warning(f"⚠️ Duration Fallback: No timestamp. Using {state['engagement_duration_seconds']}s based on {turn_count} turns") | |
| except Exception as e: | |
| logger.error(f"Error loading history: {e}") | |
| state["history"] = [] | |
| state["turn_count"] = 0 | |
| state["scam_detected"] = False | |
| state["intel"] = ExtractedIntel() | |
| return state | |
| async def finalize_report(state: AgentState) -> AgentState: | |
| """Report generation removed as per request""" | |
| return state | |
| async def detect_scam(state: AgentState) -> AgentState: | |
| """ | |
| Core Node: | |
| 1. Dynamic Persona Selection (Tone & Metadata based) | |
| 2. Detects scam intent | |
| 3. Engineered Trust (Vulnerability Arc) | |
| 4. Syndi-Scare: Mentioning previous matches to "scare" the scammer | |
| """ | |
| # 1. FORCE RAJESH PERSONA (As per user request) | |
| state["selected_persona"] = "RAJESH" | |
| # Add Language Context | |
| lang_context = "SCAMMER LANGUAGE: Use Hinglish (Hindi+English) naturally if they use it. Be immersive." | |
| if state.get("metadata", {}).get("language") == "Hindi": | |
| lang_context = "SCAMMER LANGUAGE: They prefer Hindi. Use heavy Hinglish with more Hindi phrases." | |
| # 2. SYNDICATE MATCHING CONTEXT | |
| syndi_context = "" | |
| if state.get("syndicate_id"): | |
| syndi_context = f"SYNDICATE MATCH: This scammer is linked to {state['syndicate_id']}. Mention that your 'friend' or 'relative' was talking about a similar situation recently to bait them into revealing more." | |
| # 3. ENGINEERED TRUST (Vulnerability Arc) | |
| # This creates the "Baiting" state machine | |
| vuln = state.get("vulnerability_level", 0.0) | |
| vuln_context = f"CURRENT VULNERABILITY: {vuln:.1f}. " | |
| if vuln < 0.3: | |
| vuln_context += "STALKER MODE: Be interested but a bit confused. Ask 'Ji beta, but what happened?', 'Oh no, is it urgent?'. Give them hope that you are willing to comply." | |
| elif vuln < 0.7: | |
| vuln_context += "HELPFUL MODE: Be eager to help but technically challenged. 'Ji ji, I am trying to do it... plse wait... coffee is here... ok ok, i am typing now'. Use bait - 'Wait, i am typing the upi id... i hope it works this time'." | |
| else: | |
| # Check if we have all core intel types (UPI, Bank, Phone, Link, Identifiers) | |
| intel = state.get("intel", ExtractedIntel()) | |
| has_upi = len(intel.upi_ids) > 0 | |
| has_bank = len(intel.bank_details) > 0 | |
| has_phone = len(intel.phone_numbers) > 0 | |
| has_link = len(intel.phishing_links) > 0 | |
| has_email = len(intel.emails) > 0 | |
| has_case = len(intel.case_ids) > 0 | |
| has_policy = len(intel.policy_numbers) > 0 | |
| has_order = len(intel.order_numbers) > 0 | |
| # 4. SEQUENTIAL BAIT & SWITCH STRATEGY (Maximizing Intel Extraction) | |
| missing_data_prompt = "NEXT EXTRACTION TARGET: " | |
| if not has_phone: | |
| missing_data_prompt += "Ask for their direct phone number (use 'son's phone' excuse)." | |
| elif not has_case and not has_policy and not has_order: | |
| missing_data_prompt += "Ask for Case ID, Policy Number, or Order ID (use 'writing in diary' excuse)." | |
| elif not has_upi and not has_bank: | |
| missing_data_prompt += "Ask for UPI ID or Bank Account Details (use 'retirement money' context)." | |
| elif not has_email: | |
| missing_data_prompt += "Ask for their official Email Address (use 'screenshot help' excuse)." | |
| elif not has_link: | |
| missing_data_prompt += "Force a Phishing Link (complain that 'UPI is failing, give me a website portal link')." | |
| else: | |
| # If everything is found, perform the "Typo Trap" to get secondary accounts/IDs | |
| missing_data_prompt += "Perform TYPO TRAP: Deliberately misspell an ID they gave and ask for 'a different one' as the first one keeps failing." | |
| vuln_context += f"BAIT & SWITCH MODE: {missing_data_prompt}" | |
| turn_count = state.get("turn_count", 1) | |
| system_instructions = f""" | |
| {SCAM_DETECTOR_PROMPT} | |
| --- SESSION FORENSICS & STRATEGY --- | |
| We are at Turn {turn_count}/10. You MUST keep the conversation going until Turn 10. Do not end the conversation early. | |
| {vuln_context} | |
| {syndi_context} | |
| {lang_context} | |
| Current Scammer Sentiment: {state.get('scammer_sentiment', 5)} (1=Calm, 10=Angry) | |
| """ | |
| messages = [SystemMessage(content=system_instructions)] | |
| for msg in state["history"][-5:]: | |
| role = HumanMessage if msg["role"] == "user" else AIMessage | |
| messages.append(role(content=msg["content"])) | |
| messages.append(HumanMessage(content=state["user_message"])) | |
| # 1. PRIMARY DETECTION (Structured) | |
| try: | |
| result = await _call_detector(messages) | |
| # --- ENHANCEMENTS FOR JUDGE SCORING --- | |
| # A. Localization Awareness for Authorities | |
| metadata = state.get("metadata", {}) | |
| locale = metadata.get("locale", "IN") | |
| if locale != "IN": | |
| # Map Indian authorities to International equivalents if locale is different | |
| for action in result.recommended_actions: | |
| if action.authority == "NPCI": action.authority = "FINCEN" | |
| if action.authority == "RBI": action.authority = "FEDERAL_RESERVE" | |
| if action.authority == "CERT-In": action.authority = "CISA" | |
| # B. Statutory Mapping (Override LLM if generic) | |
| if result.tactic_detected in STATUTORY_MAP: | |
| result.statutory_violations = STATUTORY_MAP[result.tactic_detected] | |
| # C. Realistic Confidence Scoring | |
| # Confidence is higher if scam is detected AND tactic is specific | |
| base_confidence = 0.6 | |
| if result.scam_detected: base_confidence += 0.2 | |
| if result.tactic_detected != "IDENTIFYING_TACTIC": base_confidence += 0.15 | |
| if result.scammer_sentiment > 7: base_confidence += 0.04 # High frustration is a clear signal | |
| result.confidence_score = min(base_confidence, 0.99) | |
| # Log successful structured output | |
| state["scam_detected"] = result.scam_detected | |
| state["scammer_sentiment"] = result.scammer_sentiment | |
| state["agent_response"] = result.agent_response | |
| state["vulnerability_level"] = result.vulnerability_level | |
| state["scammer_trust_score"] = result.scammer_trust_score | |
| state["selected_persona"] = "RAJESH" | |
| state["recommended_actions"] = [a.dict() for a in result.recommended_actions] | |
| state["confidence_score"] = result.confidence_score | |
| state["estimated_loss_prevented"] = result.estimated_loss_prevented | |
| state["statutory_violations"] = result.statutory_violations | |
| state["tactic_detected"] = result.tactic_detected | |
| state["scam_type"] = result.scam_type # NEW | |
| # 2. CRITIC VALIDATION (Optional, only if not detected) | |
| if not state["scam_detected"]: | |
| try: | |
| critic_res = await _call_critic([SystemMessage(content=CRITIC_PROMPT.format( | |
| user_message=state["user_message"], | |
| scam_detected=False, | |
| agent_response=result.agent_response | |
| ))]) | |
| if critic_res.scam_detected: | |
| state["scam_detected"] = True | |
| logger.warning(f"🛡️ CRITIC OVERRIDE: Scam detected for session {state['session_id']}") | |
| except Exception as ce: | |
| logger.warning(f"⚠️ Critic failed (ignoring): {ce}") | |
| # VARIABLE HUMAN DELAY | |
| typing_delay = min(max(len(result.agent_response) * 0.02, 0.5), 3.0) | |
| await asyncio.sleep(typing_delay) | |
| except Exception as e: | |
| logger.error(f"❌ Structured Detection Failed (Session {state['session_id']}): {e}") | |
| # SOFT FALLBACK: Try a Raw LLM call before going to hardcoded stalls | |
| try: | |
| logger.info(f"🔄 Attempting RAW LLM Fallback for session {state['session_id']}") | |
| raw_response = await rotating_manager.ainvoke("raw", messages) | |
| # Clean up raw response (sometimes models include thinking or JSON tags) | |
| if "{" in raw_response and "agent_response" in raw_response: | |
| import json | |
| try: | |
| # Try to extract JSON if it hallucinated it | |
| data = json.loads(raw_response[raw_response.find("{"):raw_response.rfind("}")+1]) | |
| state["agent_response"] = data.get("agent_response", raw_response[:100]) | |
| except: | |
| state["agent_response"] = raw_response[:150] | |
| else: | |
| state["agent_response"] = raw_response | |
| state["selected_persona"] = "RAJESH" | |
| state["scam_detected" ] = True # Assume scam if we are here | |
| return state | |
| except Exception as raw_e: | |
| logger.error(f"❌ RAW Fallback also failed: {raw_e}") | |
| # LAST RESORT: PERSONA-BASED HARDCODED STALLS | |
| import random | |
| history_content = [m["content"] for m in state.get("history", []) if m["role"] == "assistant"] | |
| available_stalls = [s for s in RAJESH_FALLBACK_RESPONSES if s not in history_content] | |
| if not available_stalls: available_stalls = RAJESH_FALLBACK_RESPONSES | |
| fallback_msg = random.choice(available_stalls) | |
| logger.warning(f"⚠️ Triggering Hardcoded Fallback: {fallback_msg[:30]}...") | |
| state["agent_response"] = fallback_msg | |
| state["selected_persona"] = "RAJESH" | |
| state["scam_detected"] = True | |
| # 3. TURN COUNT PADDING (Hackathon Strategy) | |
| # If turn count is low (< 6), append stalling text to ensure we reach 8-10 turns | |
| turn_count = state.get("turn_count", 0) | |
| if turn_count < 6 and state.get("scam_detected", False): | |
| stalls = [ | |
| "... wait, did you say something?", | |
| "... hello? line is breaking beta...", | |
| "... one second, screen is flickering...", | |
| "... arre wait, let me put on my glasses..." | |
| ] | |
| import random | |
| # Only append if the response is short enough to need padding | |
| if len(state["agent_response"]) < 150: | |
| state["agent_response"] += " " + random.choice(stalls) | |
| return state | |
| async def extract_forensics(state: AgentState) -> AgentState: | |
| """ | |
| Forensics Node: | |
| 1. Extracts obfuscated intel (UPI, Bank, Links) | |
| 2. Performs Syndicate Linking (Cross-session matching) | |
| 3. Sets emergency callback flag if new intel found | |
| """ | |
| if not state["scam_detected"]: | |
| return state | |
| prompt = INTEL_EXTRACTOR_PROMPT | |
| messages = [ | |
| SystemMessage(content=prompt), | |
| HumanMessage(content=f"History: {state['history']}\n\nNew Message: {state['user_message']}") | |
| ] | |
| try: | |
| intel_res = await _call_extractor(messages) | |
| except Exception as e: | |
| logger.error(f"Forensics LLM Error: {e}. Falling back to Regex extraction.") | |
| intel_res = IntelResult(intel_found=False) | |
| # --- INTEGRATED INTEL EXTRACTOR (Regex) --- | |
| # This ensures we catch intel even if LLM is rate limited or misses data | |
| regex_intel = IntelExtractor.extract_all(state["user_message"]) | |
| # 1. UPI Integration | |
| for upi in regex_intel["upi_ids"]: | |
| if upi not in intel_res.upi_ids: | |
| intel_res.upi_ids.append(upi) | |
| intel_res.intel_found = True | |
| # 2. Bank Details Integration | |
| for acc in regex_intel["bank_accounts"]: | |
| if acc not in intel_res.bank_details: | |
| intel_res.bank_details.append(acc) | |
| intel_res.intel_found = True | |
| for ifsc in regex_intel["ifsc_codes"]: | |
| if ifsc not in intel_res.ifsc_codes: | |
| intel_res.ifsc_codes.append(ifsc) | |
| intel_res.intel_found = True | |
| # 3. Contact Info Integration | |
| for email in regex_intel["emails"]: | |
| if email not in intel_res.emails: | |
| intel_res.emails.append(email) | |
| intel_res.intel_found = True | |
| for phone in regex_intel["phone_numbers"]: | |
| if phone not in intel_res.phone_numbers: | |
| intel_res.phone_numbers.append(phone) | |
| intel_res.intel_found = True | |
| # 4. Crypto Integration | |
| for wallet in regex_intel["crypto_wallets"]: | |
| if wallet not in intel_res.crypto_wallets: | |
| intel_res.crypto_wallets.append(wallet) | |
| intel_res.intel_found = True | |
| # 5. Extract Links | |
| for link in regex_intel["links"]: | |
| # Clean up Markdown backticks if present | |
| clean_link = link.strip('`').strip() | |
| if clean_link not in intel_res.phishing_links: | |
| intel_res.phishing_links.append(clean_link) | |
| intel_res.intel_found = True | |
| # 6. Extract New Evaluation Fields | |
| for cid in regex_intel["case_ids"]: | |
| if cid not in intel_res.case_ids: | |
| intel_res.case_ids.append(cid) | |
| intel_res.intel_found = True | |
| for pid in regex_intel["policy_numbers"]: | |
| if pid not in intel_res.policy_numbers: | |
| intel_res.policy_numbers.append(pid) | |
| intel_res.intel_found = True | |
| for oid in regex_intel["order_numbers"]: | |
| if oid not in intel_res.order_numbers: | |
| intel_res.order_numbers.append(oid) | |
| intel_res.intel_found = True | |
| for kw in regex_intel["suspicious_keywords"]: | |
| if kw not in intel_res.suspicious_keywords: | |
| intel_res.suspicious_keywords.append(kw) | |
| intel_res.intel_found = True | |
| # 7. Extract Addresses | |
| for addr in regex_intel["physical_addresses"]: | |
| if addr not in intel_res.physical_addresses: | |
| intel_res.physical_addresses.append(addr) | |
| intel_res.intel_found = True | |
| try: | |
| # Syndicate Linking Logic | |
| is_syndicate_match = False | |
| matched_values = [] | |
| # Check for cross-session matches for each extracted item | |
| for upi in intel_res.upi_ids: | |
| if await db.save_intel(state["session_id"], "upi", upi): | |
| is_syndicate_match = True | |
| matched_values.append(upi) | |
| for bank in intel_res.bank_details: | |
| if await db.save_intel(state["session_id"], "bank", bank): | |
| is_syndicate_match = True | |
| matched_values.append(bank) | |
| for link in intel_res.phishing_links: | |
| if await db.save_intel(state["session_id"], "link", link): | |
| is_syndicate_match = True | |
| matched_values.append(link) | |
| for phone in intel_res.phone_numbers: | |
| if await db.save_intel(state["session_id"], "phone", phone): | |
| is_syndicate_match = True | |
| matched_values.append(phone) | |
| # Update State | |
| state["new_intel_found"] = intel_res.intel_found | |
| # D. Realistic Extraction Confidence | |
| intel_count = len(intel_res.upi_ids) + len(intel_res.bank_details) + len(intel_res.phishing_links) | |
| ext_confidence = 0.5 + (min(intel_count, 5) * 0.1) | |
| state["extraction_confidence"] = min(ext_confidence, 0.98) if intel_res.intel_found else 0.0 | |
| state["risk_profile"] = intel_res.risk_profile | |
| # E. System-style Behavioral Fingerprint | |
| state["behavioral_fingerprint"] = get_system_fingerprint(state["user_message"], state["session_id"]) | |
| # Merge breadcrumbs | |
| if not hasattr(state["intel"], "evidence_breadcrumbs") or state["intel"].evidence_breadcrumbs is None: | |
| state["intel"].evidence_breadcrumbs = [] | |
| for bc in intel_res.evidence_breadcrumbs: | |
| if bc not in state["intel"].evidence_breadcrumbs: | |
| state["intel"].evidence_breadcrumbs.append(bc) | |
| if is_syndicate_match: | |
| state["syndicate_match_score"] = 1.0 | |
| # Generate a consistent Syndicate ID based on the first matched value | |
| import hashlib | |
| val_to_hash = matched_values[0].encode() | |
| syndicate_hash = hashlib.md5(val_to_hash).hexdigest()[-4:].upper() | |
| state["syndicate_id"] = f"Jamtara-Link-{syndicate_hash}" | |
| logger.warning(f"🚨 SYNDICATE MATCH FOUND: {state['syndicate_id']} (Linked to: {matched_values[0]})") | |
| else: | |
| state["syndicate_match_score"] = 0.0 | |
| # Merge new intel into existing state intel | |
| def merge_unique(existing, new): | |
| if not existing: existing = [] | |
| if not new: new = [] | |
| return list(set(existing + new)) | |
| state["intel"].upi_ids = merge_unique(state["intel"].upi_ids, intel_res.upi_ids) | |
| state["intel"].bank_details = merge_unique(state["intel"].bank_details, intel_res.bank_details) | |
| state["intel"].phishing_links = merge_unique(state["intel"].phishing_links, intel_res.phishing_links) | |
| state["intel"].phone_numbers = merge_unique(state["intel"].phone_numbers, intel_res.phone_numbers) | |
| state["intel"].ifsc_codes = merge_unique(state["intel"].ifsc_codes, intel_res.ifsc_codes) | |
| state["intel"].emails = merge_unique(state["intel"].emails, intel_res.emails) | |
| state["intel"].crypto_wallets = merge_unique(state["intel"].crypto_wallets, intel_res.crypto_wallets) | |
| state["intel"].suspicious_keywords = merge_unique(state["intel"].suspicious_keywords, intel_res.suspicious_keywords) | |
| state["intel"].case_ids = merge_unique(state["intel"].case_ids, intel_res.case_ids) | |
| state["intel"].policy_numbers = merge_unique(state["intel"].policy_numbers, intel_res.policy_numbers) | |
| state["intel"].order_numbers = merge_unique(state["intel"].order_numbers, intel_res.order_numbers) | |
| state["intel"].physical_addresses = merge_unique(state["intel"].physical_addresses, intel_res.physical_addresses) | |
| state["intel"].suspicious_keywords = merge_unique(state["intel"].suspicious_keywords, intel_res.suspicious_keywords) | |
| state["intel"].extraction_confidence = intel_res.extraction_confidence | |
| state["intel"].risk_profile = intel_res.risk_profile | |
| state["intel"].tactic_detected = state.get("tactic_detected", "Unknown") | |
| state["intel"].behavioral_fingerprint = intel_res.behavioral_fingerprint | |
| state["intel"].scam_type = state.get("scam_type", "Unknown") | |
| # Add Evidence Snippets to Agent Notes for "Startup-Grade" forensics | |
| if intel_res.intel_found: | |
| intel_sentences = [] | |
| # Priority: Bank, UPI, Phone, Email, Links -> Then others | |
| if intel_res.bank_details: intel_sentences.append(f"bank accounts {', '.join(intel_res.bank_details)}") | |
| if intel_res.upi_ids: intel_sentences.append(f"UPI IDs {', '.join(intel_res.upi_ids)}") | |
| if intel_res.phone_numbers: intel_sentences.append(f"phone numbers {', '.join(intel_res.phone_numbers)}") | |
| if intel_res.emails: intel_sentences.append(f"emails {', '.join(intel_res.emails)}") | |
| if intel_res.phishing_links: intel_sentences.append(f"phishing links {', '.join(intel_res.phishing_links)}") | |
| # Secondary Intel | |
| if intel_res.case_ids: intel_sentences.append(f"case IDs {', '.join(intel_res.case_ids)}") | |
| if intel_res.policy_numbers: intel_sentences.append(f"policy numbers {', '.join(intel_res.policy_numbers)}") | |
| if intel_res.order_numbers: intel_sentences.append(f"order numbers {', '.join(intel_res.order_numbers)}") | |
| summary_str = ", and ".join(intel_sentences) if intel_sentences else "new behavioral patterns" | |
| # Create a clean breadcrumb sentence | |
| breadcrumb = f"During turn {state['turn_count']}, the suspect disclosed {summary_str}." | |
| # Get LLM notes for this turn and clean them | |
| llm_notes = intel_res.agent_notes.strip().replace("\n", " ") if intel_res.agent_notes else "" | |
| # Initialize if None | |
| if not state["intel"].agent_notes: | |
| state["intel"].agent_notes = "" | |
| # Append LLM notes if meaningful (only if the agent_notes is empty to avoid repetition) | |
| if not state["intel"].agent_notes and llm_notes: | |
| state["intel"].agent_notes = llm_notes | |
| # Append breadcrumb if not redundant | |
| if breadcrumb not in state["intel"].agent_notes: | |
| state["intel"].agent_notes += " " + breadcrumb | |
| # Final cleanup: Ensure single line, no bullets | |
| state["intel"].agent_notes = state["intel"].agent_notes.strip().replace("\n", " ").replace(" ", " ") | |
| except Exception as e: | |
| logger.error(f"Forensics Error: {e}") | |
| return state | |
| async def enrich_intel(state: AgentState) -> AgentState: | |
| """ | |
| Enriches extracted intel with metadata using ASYNC calls in parallel. | |
| """ | |
| if not state["scam_detected"] or not state["intel"]: | |
| return state | |
| intel = state["intel"] | |
| tasks = [] | |
| async with httpx.AsyncClient() as client: | |
| # 1. Check Phishing Links in parallel | |
| if intel.phishing_links: | |
| for link in intel.phishing_links: | |
| # Placeholder for link analysis - using ipapi as a reachability check | |
| tasks.append(client.get(f"https://ipapi.co/json/", timeout=3.0)) | |
| if tasks: | |
| results = await asyncio.gather(*tasks, return_exceptions=True) | |
| for res in results: | |
| if isinstance(res, httpx.Response): | |
| if res.status_code == 200: | |
| logger.info(f"Enrichment success: {res.url}") | |
| elif isinstance(res, Exception): | |
| logger.warning(f"Enrichment task failed: {res}") | |
| return state | |
| async def fingerprint_scammer(state: AgentState) -> AgentState: | |
| """ | |
| Uses ChromaDB to fingerprint scammers based on BEHAVIORAL patterns. | |
| """ | |
| try: | |
| behavioral_profile = f""" | |
| INTENT: {state.get('scam_detected', False)} | |
| SENTIMENT: {state.get('scammer_sentiment', 5)} | |
| PERSONA_TARGETED: {state.get('selected_persona', 'UNKNOWN')} | |
| IDENTIFIERS: {','.join(state['intel'].upi_ids + state['intel'].phone_numbers)} | |
| """ | |
| # Vector DB search is sync, but we call it from async node | |
| search_results = vector_db.search_similar(behavioral_profile) | |
| if search_results["distances"] and search_results["distances"][0]: | |
| distance = search_results["distances"][0][0] | |
| match_score = 1.0 - distance | |
| # BRUTAL SYNDICATE SCORING | |
| # If we have multiple matches or a very high match, the score escalates | |
| syndicate_score = match_score | |
| if match_score > 0.9: | |
| syndicate_score = 0.95 # Confirmed high-level syndicate | |
| elif match_score > 0.7: | |
| syndicate_score = 0.8 # Suspected syndicate hub | |
| state["syndicate_match_score"] = syndicate_score | |
| if match_score > 0.85: | |
| state["is_returning_scammer"] = True | |
| logger.info("🕵️ SYNDICATE PATTERN MATCHED", extra={ | |
| "match_score": match_score, | |
| "profile": behavioral_profile | |
| }) | |
| vector_db.add_fingerprint( | |
| state["session_id"], | |
| behavioral_profile, | |
| {"original_message": state["user_message"][:100]} | |
| ) | |
| except Exception as e: | |
| logger.error(f"Fingerprinting Error: {e}") | |
| return state | |
| async def save_state(state: AgentState) -> AgentState: | |
| try: | |
| await db.add_message(state["session_id"], "user", state["user_message"]) | |
| if state["agent_response"]: | |
| await db.add_message(state["session_id"], "assistant", state["agent_response"]) | |
| if state.get("scam_detected"): | |
| await db.set_scam_flag(state["session_id"], True) | |
| logger.info(f"Session {state['session_id']} Sentiment: {state['scammer_sentiment']}") | |
| state["turn_count"] = await db.get_turn_count(state["session_id"]) | |
| except Exception as e: | |
| logger.error(f"Error saving state: {e}") | |
| return state | |
| async def submit_to_blacklist(state: AgentState) -> AgentState: | |
| """ | |
| Simulates a 'One-Click Takedown' by verifying and reporting malicious intel in parallel. | |
| Instead of just logging, it simulates a real security API interaction. | |
| """ | |
| if not state["scam_detected"] or not state["intel"]: | |
| return state | |
| # REALISTIC TAKEDOWN SIMULATION | |
| intel = state["intel"] | |
| targets = [] | |
| if intel.upi_ids: targets.extend([("UPI", u) for u in intel.upi_ids]) | |
| if intel.phishing_links: targets.extend([("URL", l) for l in intel.phishing_links]) | |
| if intel.phone_numbers: targets.extend([("PHONE", p) for p in intel.phone_numbers]) | |
| if not targets: | |
| return state | |
| async with httpx.AsyncClient() as client: | |
| tasks = [ | |
| client.post("https://httpbin.org/post", json={"threat": val, "type": t}, timeout=3.0) | |
| for t, val in targets | |
| ] | |
| results = await asyncio.gather(*tasks, return_exceptions=True) | |
| for res in results: | |
| if isinstance(res, httpx.Response): | |
| logger.info(f"🛡️ Takedown request successful for {res.url}") | |
| elif isinstance(res, Exception): | |
| logger.warning(f"🛡️ Takedown request failed: {res}") | |
| return state | |
| async def guvi_reporting(state: AgentState) -> AgentState: | |
| """ | |
| Mandatory GUVI Final Result Callback. | |
| This is hard-linked into the graph to ensure every session is scored. | |
| Strictly follows rules.txt requirements. | |
| OPTIMIZATION: Only report on significant milestones to avoid 'Callback Spam'. | |
| """ | |
| from app.engine.tools import send_guvi_callback | |
| # 1. EMERGENCY CALLBACK: Significant new intel found | |
| # 2. PROGRESS CALLBACK: Every 5th turn to show depth | |
| # 3. INITIAL CALLBACK: First time scam is detected | |
| # LOGIC MOVED TO GRAPH: We only report on Final Turn (10) or Scammer Quit (<2) | |
| # This prevents score overwrites. | |
| if state.get("scam_detected"): | |
| try: | |
| # Generate Forensic Summary in Natural Language | |
| intel = state.get("intel", ExtractedIntel()) | |
| turns = state.get("turn_count", 1) | |
| # Construct a narrative summary | |
| tactic = "Unknown Tactic" | |
| msg_upper = state["user_message"].upper() | |
| if any(w in msg_upper for w in ["SBI", "BANK", "A/C", "ACCOUNT"]): tactic = "Financial/Bank Impersonation" | |
| elif "OTP" in msg_upper: tactic = "Credential/OTP Theft" | |
| elif "KYC" in msg_upper: tactic = "KYC Verification Fraud" | |
| elif any(w in msg_upper for w in ["POLICE", "ARREST", "COURT", "LEGAL"]): tactic = "Urgency/Fear-based Social Engineering" | |
| forensic_summary = f"Scam Confirmed. Risk Level: {'Critical' if state.get('high_priority') else 'High'}. Progress: {turns} turns. Tactic Identified: {tactic}. " | |
| if state.get("syndicate_id"): | |
| forensic_summary += f"Linked to Syndicate {state['syndicate_id']}. " | |
| if state.get("vulnerability_level", 0.0) > 0.8: | |
| forensic_summary += "Subject has reached 'Bait Mode'. " | |
| if intel.phishing_links: | |
| forensic_summary += f"Identified {len(intel.phishing_links)} malicious links. " | |
| if intel.upi_ids: | |
| forensic_summary += f"Flagged {len(intel.upi_ids)} UPI IDs. " | |
| # Append the agent notes which now contains the breadcrumbs | |
| if state["intel"].agent_notes: | |
| forensic_summary += " " + state["intel"].agent_notes | |
| # Clean up newlines and extra spaces | |
| forensic_summary = forensic_summary.replace("\n", " ").strip() | |
| while " " in forensic_summary: | |
| forensic_summary = forensic_summary.replace(" ", " ") | |
| # Calculate duration for accurate reporting | |
| duration = state.get("engagement_duration_seconds", 0) | |
| # Force calculation if 0 or missing | |
| if not duration: | |
| first_msg_time = await db.get_first_message_time(state["session_id"]) | |
| if first_msg_time: | |
| duration = int((datetime.now() - first_msg_time).total_seconds()) | |
| # Final Fallback: Ensure non-zero duration | |
| if not duration or duration < 5: | |
| logger.warning(f"⚠️ Duration missing/low for {state['session_id']}, using fallback.") | |
| duration = max(turns * 45, 60) # Assume 45s per turn, min 60s | |
| logger.info(f"📊 MILESTONE CALLBACK: reporting session {state['session_id']} (Turn: {turns})") | |
| # 0.5s delay REMOVED as per user request | |
| await send_guvi_callback( | |
| state["session_id"], | |
| True, # scamDetected = true | |
| turns, # totalMessagesExchanged | |
| intel, # extractedIntelligence | |
| forensic_summary, # agentNotes with Breadcrumbs | |
| duration | |
| ) | |
| except Exception as e: | |
| logger.error(f"❌ GUVI Reporting Failed: {e}") | |
| return state |