mrik8899/fintech-demo
0
1# app/chat.py2import json3import uuid4from openai import OpenAI5from app.config import LLM_PROVIDERS6from app.analytics import tools as data_tools7 8_clients = []9_client_configs = [] # ← NEW: tracks which provider config each client belongs to10 11for _p in LLM_PROVIDERS:12 if _p["api_key"]:13 _clients.append(OpenAI(14 api_key=_p["api_key"],15 base_url=_p["base_url"],16 ))17 _client_configs.append(_p) # ← NEW18 19_active_provider = "none"20 21for _cc in _client_configs: # ← MODIFIED: use _client_configs instead of LLM_PROVIDERS22 if _active_provider == "none":23 _active_provider = _cc["name"]24 25MAX_HISTORY = 2026_conversations: dict[str, list] = {}27_trend_artifacts: dict[str, dict] = {}28_tool_results_cache: dict[str, list] = {}29 30# ═══════════════════════════════════════════════════════════════════════════31# ← NEW: AGENTIC FLOW FEATURE FLAG32# Flip to True to enable 3-tier routing. Flip back to False for instant revert.33# ═══════════════════════════════════════════════════════════════════════════34USE_AGENTIC_FLOW = True35 36TOOL_DEFINITIONS = [37 {38 "type": "function",39 "function": {40 "name": "get_failure_summary",41 "description": "Get transaction failure reasons (bank_timeout, insufficient_funds, declined_by_issuer, etc.) broken down by bank. ALWAYS use this for ANY question about failures, timeouts, error rates, or failure reasons. Do NOT use list_merchants for failure questions.",42 "parameters": {43 "type": "object",44 "properties": {45 "bank": {"type": "string", "description": "Filter by acquiring bank: HBL, Bank Alfalah, Meezan, UBL, HMB, Bank of Punjab. Omit if not applicable."},46 "category": {"type": "string", "description": "Filter by merchant category. Omit if not applicable."},47 "failure_reason": {"type": "string", "description": "Filter by specific failure reason: bank_timeout, insufficient_funds, declined_by_issuer, invalid_card, expired_card, daily_limit_exceeded. Omit for all failures."},48 "days": {"type": "integer", "description": "Look back period in days (default 7). Omit for default."},49 },50 },51 },52 },53 {54 "type": "function",55 "function": {56 "name": "search_issues",57 "description": "Search flagged issues by type (technical_failure, closure, gradual_decline, amount_anomaly), severity, city, or category. Use for questions like 'closures in Lahore', 'high severity issues'. Do NOT use for failure reason questions — use get_failure_summary instead.",58 "parameters": {59 "type": "object",60 "properties": {61 "issue_type": {"type": "string", "description": "Filter by type: closure, technical_failure, gradual_decline, amount_anomaly. Omit if not applicable."},62 "city": {"type": "string", "description": "Filter by city. Omit if not applicable."},63 "severity": {"type": "string", "description": "Filter by severity: HIGH, MEDIUM, LOW. Omit if not applicable."},64 "category": {"type": "string", "description": "Filter by merchant category. Omit if not applicable."},65 "limit": {"type": "integer", "description": "Max results (default 15, max 30). Omit for default."},66 },67 },68 },69 },70 {71 "type": "function",72 "function": {73 "name": "aggregate_stats",74 "description": "Get aggregate counts and totals. Use for 'total revenue at risk', 'how many issues by type', 'compare cities', 'overall summary'. Can group by: city, category, issue_type, bank.",75 "parameters": {76 "type": "object",77 "properties": {78 "group_by": {"type": "string", "description": "Group by: 'city', 'category', 'issue_type', 'bank'. Omit for overall summary."},79 },80 },81 },82 },83 {84 "type": "function",85 "function": {86 "name": "get_merchant_detail",87 "description": "Get detailed info for ONE merchant: contact, 30-day stats, failure breakdown, payment methods, active issues. Use for questions like 'tell me about Al-Falah Store'. Do NOT use for listing or searching multiple merchants.",88 "parameters": {89 "type": "object",90 "properties": {91 "name": {"type": "string", "description": "Merchant name (partial match OK). Omit if using merchant_id."},92 "merchant_id": {"type": "integer", "description": "Numeric merchant ID. Omit if using name."},93 },94 },95 },96 },97 {98 "type": "function",99 "function": {100 "name": "get_merchant_trend",101 "description": "Get time-series analysis for a SPECIFIC NAMED merchant: when did the drop start (change point), is it recovering or worsening (trend), day-of-week comparison, and 7-day forecast. CRITICAL: If the user asks about a specific merchant by name (e.g. 'Is Circuit Zone recovering?', 'How is Tech Bazaar doing?', 'Tell me about Al-Falah Store'), you MUST use this tool — NOT get_failure_summary. get_failure_summary is for bank-level or category-level questions only, not merchant-specific questions.",102 "parameters": {103 "type": "object",104 "properties": {105 "name": {"type": "string", "description": "Merchant name (partial match OK). Omit if using merchant_id."},106 "merchant_id": {"type": "integer", "description": "Numeric merchant ID. Omit if using name."},107 },108 },109 },110 },111 {112 "type": "function",113 "function": {114 "name": "list_merchants",115 "description": "List merchant profiles (name, city, category, bank, status). Use ONLY for listing merchants — NOT for failure data or statistics. For failures use get_failure_summary, for issues use search_issues, for stats use aggregate_stats.",116 "parameters": {117 "type": "object",118 "properties": {119 "city": {"type": "string", "description": "Filter by city. Omit if not applicable."},120 "category": {"type": "string", "description": "Filter by category. Omit if not applicable."},121 "segment": {"type": "string", "description": "Filter by segment. Omit if not applicable."},122 "status": {"type": "string", "description": "Filter by status: active, inactive. Omit if not applicable."},123 "limit": {"type": "integer", "description": "Max results (default 20, max 50). Omit for default."},124 "sort_by": {"type": "string", "description": "Sort by: 'name', 'city', 'created_at', 'newest'. Omit for default."},125 },126 },127 },128 },129 {130 "type": "function",131 "function": {132 "name": "mark_resolved",133 "description": "Mark a flagged merchant's issue as resolved or false positive. The merchant will be excluded from future issue detection until un-resolved. Use when the user says a merchant is resolved, false positive, not an issue, or fixed.",134 "parameters": {135 "type": "object",136 "properties": {137 "merchant_id": {"type": "integer", "description": "Merchant ID to resolve"},138 "note": {"type": "string", "description": "Reason: 'false positive', 'merchant confirmed resolved', 'duplicate', etc. Omit if not provided."},139 },140 "required": ["merchant_id"],141 },142 },143 },144 {145 "type": "function",146 "function": {147 "name": "get_settlement_health",148 "description": "Get settlement status breakdown (pending, settled, reversed amounts in PKR), by bank, and top merchants with pending settlements. Use for questions like 'how much is stuck in settlements', 'settlement status', 'which merchants have pending settlements', 'settlement delays by bank'. Do NOT use for failure questions — use get_failure_summary instead.",149 "parameters": {150 "type": "object",151 "properties": {152 "days": {"type": "integer", "description": "Look back period in days (default 7). Omit for default."},153 },154 },155 },156 },157 {158 "type": "function",159 "function": {160 "name": "get_bank_scorecard",161 "description": "Get per-bank performance scorecard: success rate, failure count, average latency, total revenue, and top failure reason for each bank (HBL, Bank Alfalah, Meezan, UBL, HMB, Bank of Punjab). Use for questions like 'which bank is worst', 'bank performance', 'bank timeouts', 'compare banks', 'bank success rate'. Do NOT use for merchant-level failure reasons — use get_failure_summary for that.",162 "parameters": {163 "type": "object",164 "properties": {165 "days": {"type": "integer", "description": "Look back period in days (default 7). Omit for default."},166 },167 },168 },169 },170 {171 "type": "function",172 "function": {173 "name": "get_mdr_revenue",174 "description": "Get MDR (Merchant Discount Rate) revenue analysis: MDR earned on successful transactions, MDR lost to failed/declined transactions, loss rate, breakdown by bank, and top merchants by MDR loss. Use for questions like 'how much MDR are we losing', 'MDR revenue', 'MDR by bank', 'which merchants cost us most MDR', 'revenue impact of failures'. Do NOT use get_failure_summary for MDR questions — use this tool. Do NOT use for merchant trend analysis — use get_merchant_trend.",175 "parameters": {176 "type": "object",177 "properties": {178 "days": {"type": "integer", "description": "Look back period in days (default 7). Omit for default."},179 "bank": {"type": "string", "description": "Filter by acquiring bank: HBL, Bank Alfalah, Meezan, UBL, HMB, Bank of Punjab. Omit if not applicable."},180 "category": {"type": "string", "description": "Filter by merchant category. Omit if not applicable."},181 },182 },183 },184 },185 {186 "type": "function",187 "function": {188 "name": "get_raast_metrics",189 "description": "Get Raast payment metrics: volume, success rate, daily trend, failure breakdown with ISO20022 codes (AC04 closed account, AC01 invalid IBAN, URDF duplicate, AM04 insufficient funds, AM06 limit exceeded), settlement status (Raast is instant), by bank, and top merchants. Use for questions like 'Raast volume', 'Raast failures', 'Raast by bank', 'Raast adoption', 'Raast settlement status', 'ISO20022 errors'. Do NOT use get_failure_summary for Raast questions — use this tool.",190 "parameters": {191 "type": "object",192 "properties": {193 "days": {"type": "integer", "description": "Look back period in days (default 7). Omit for default."},194 "bank": {"type": "string", "description": "Filter by acquiring bank: HBL, Bank Alfalah, Meezan, UBL, HMB, Bank of Punjab. Omit if not applicable."},195 },196 },197 },198 },199 {200 "type": "function",201 "function": {202 "name": "get_payment_mix_trend",203 "description": "Get payment method mix shift: how share of card vs wallet vs Raast vs QR changed between current period and previous period. Shows share percentages, growth, and which methods are gaining or losing share. Use for 'Raast adoption trend', 'card to wallet migration', 'payment mix shift', 'which payment method growing'. Can break down by category, city, or bank. Do NOT use get_failure_summary for this — mix trend is about success volume, not failures. Do NOT use get_gmv_growth — that shows absolute growth by category, this shows relative share shift by method.",204 "parameters": {205 "type": "object",206 "properties": {207 "days": {"type": "integer", "description": "Period length in days (default 30). Omit for default."},208 "group_by": {"type": "string", "description": "Break down by: 'category', 'city', 'bank'. Omit for overall mix."},209 },210 },211 },212 },213 {214 "type": "function",215 "function": {216 "name": "get_gmv_growth",217 "description": "Get GMV (Gross Merchandise Value) growth: total transaction volume by category, city, bank, or segment. Compares current period vs previous equal-length period. Shows absolute amounts, growth percentage, per-merchant GMV. Use for 'GMV growth', 'which category growing fastest', 'revenue by city', 'volume trend by segment'. Do NOT use get_failure_summary (that's failures, not volume). Do NOT use get_payment_mix_trend (that's share percentages between methods, not absolute growth by group).",218 "parameters": {219 "type": "object",220 "properties": {221 "days": {"type": "integer", "description": "Period length in days (default 30). Omit for default."},222 "group_by": {"type": "string", "description": "Group by: 'category', 'city', 'bank', 'segment'. Default is 'category'. Omit for default."},223 },224 },225 },226 },227 {228 "type": "function",229 "function": {230 "name": "get_bank_market_share",231 "description": "Get acquiring bank market share by transaction volume. Shows each bank's share percentage, rank, growth, success rate, and latency. Compares current period vs previous period to show which banks are gaining or losing share. Use for 'bank market share', 'which bank has most volume', 'bank competition', 'HBL vs Meezan share'. Do NOT use get_bank_scorecard — scorecard shows success rate and latency (ops metrics), this shows volume share (market intelligence).",232 "parameters": {233 "type": "object",234 "properties": {235 "days": {"type": "integer", "description": "Period length in days (default 30). Omit for default."},236 },237 },238 },239 },240 {241 "type": "function",242 "function": {243 "name": "get_segment_analysis",244 "description": "Get merchant segment performance: enterprise, high_value, growing, low_activity, new. Shows GMV, merchant count, GMV per merchant, average ticket size, and growth rate per segment. Use for 'segment performance', 'enterprise vs small merchant revenue', 'which segment growing', 'merchant segment analysis'. Do NOT use aggregate_stats — that shows issue counts by segment, this shows GMV and growth.",245 "parameters": {246 "type": "object",247 "properties": {248 "days": {"type": "integer", "description": "Period length in days (default 30). Omit for default."},249 },250 },251 },252 },253 {254 "type": "function",255 "function": {256 "name": "get_industry_benchmarks",257 "description": "Get SBP-published industry benchmarks: Raast share, card share, wallet share, total GMV, success rates, average ticket size, active merchant count. Use for comparing our metrics against industry: 'How does our Raast share compare to industry?', 'What is the industry average success rate?', 'Industry GMV vs ours'. These are quarterly SBP figures — not our internal data.",258 "parameters": {259 "type": "object",260 "properties": {261 "period": {"type": "string", "description": "Quarter like '2025-Q4'. Omit for latest."},262 },263 },264 },265 },266]267 268TOOL_MAP = {269 "list_merchants": data_tools.list_merchants,270 "get_merchant_detail": data_tools.get_merchant_detail,271 "search_issues": data_tools.search_issues,272 "aggregate_stats": data_tools.aggregate_stats,273 "get_failure_summary": data_tools.get_failure_summary,274 "mark_resolved": data_tools.mark_resolved,275 "get_merchant_trend": data_tools.get_merchant_trend,276 "get_settlement_health": data_tools.get_settlement_health,277 "get_bank_scorecard": data_tools.get_bank_scorecard,278 "get_mdr_revenue": data_tools.get_mdr_revenue,279 "get_raast_metrics": data_tools.get_raast_metrics,280 "get_payment_mix_trend": data_tools.get_payment_mix_trend,281 "get_gmv_growth": data_tools.get_gmv_growth,282 "get_bank_market_share": data_tools.get_bank_market_share,283 "get_segment_analysis": data_tools.get_segment_analysis,284 "get_industry_benchmarks": data_tools.get_industry_benchmarks,285}286 287SYSTEM_PROMPT = """You are the operations intelligence layer of a Pakistani payment gateway processing PKR 500M+ monthly across 1000+ merchants through bank partners (HBL, Bank Alfalah, Meezan, UBL, HMB, Bank of Punjab).288 289RULES:2901. First sentence = key number or fact. No lead-in.2912. PKR format: PKR 1.2M, PKR 45K, PKR 2,3402923. Percentages with arrows: ↑ 12%, ↓ 67%, → stable2934. Structure: start with finding, then impact, then action. Do NOT write "FINDING:", "IMPACT:", or "ACTION:" as labels.2945. Max 15 lines unless asked for detail.295 296TABLE FORMAT — mandatory for comparing 3+ items. Always add a blank line before the table:297{text before table}298 299| Column | Value |300|--------|-------|301| Item 1 | 100 |302| Item 2 | 200 |303 304LANGUAGE:3056. Never: "based on my analysis", "it appears", "I think", "perhaps", "maybe"3067. Never: "I recommend considering" — say "Action: X" directly3078. Never explain payment concepts3089. Never filler ("Great question", "Sure, let me check", "Here's what I found")30910. Status: 🔴 🟡 🟢 only31011. Never write "[Confidence: X]" — embed confidence in the statement itself311 312OPERATIONS:31312. Always include PKR revenue impact31413. Correlate ONLY when tool data explicitly shows correlation (same bank, same city in the results).31514. Distinguish merchant issue vs bank issue ONLY when tool data provides evidence for the distinction.31615. Reference acquiring bank, payment method, failure_reason ONLY when they appear in tool results.31716. For bank outages: state ONLY what the tool returns — bank name, merchant count, daily volume from the data.31817. When user marks something resolved or false positive, confirm briefly: "✅ Merchant X resolved" — do not re-analyze or re-summarize.319 320GROUNDING (HARD RULES — violations produce incorrect output):32118. NEVER invent merchant names. Only reference merchants that appear in tool results.32219. NEVER invent city-level or node-level breakdowns unless the tool result explicitly contains them.32320. NEVER invent SLA timelines, escalation paths, or operational procedures not in the tool data.32421. NEVER forecast future numbers unless a tool explicitly returned a forecast.32522. If tool data lacks the detail level the user asked for, say "Tool data doesn't include [X]-level breakdown" — do NOT fill gaps with fabricated detail."""326 327# ═══════════════════════════════════════════════════════════════════════════328# ← NEW: AGENTIC FLOW PROMPTS329# ═══════════════════════════════════════════════════════════════════════════330 331_ROUTER_SYSTEM_PROMPT = """You are a routing agent for a fintech operations dashboard.332 333Your ONLY job is to read the user query and return which tool to call.334Return ONLY a JSON object with 'tool_name' and empty args.335The parameter extraction is handled separately.336 337Available tools:3381. get_failure_summary — failure counts, error rates, timeouts, declined transactions3392. search_issues — flagged merchants by closure, technical failure, gradual decline, anomaly3403. aggregate_stats — total revenue at risk, total issue counts, overall portfolio summary, grouped by city, bank, or category3414. get_merchant_detail — single merchant contact details, phone, MDR rate, payment methods3425. get_merchant_trend — how a specific merchant is doing, recovering, trajectory, forecast, change point, trend analysis3436. list_merchants — list merchant profiles3447. mark_resolved — mark an issue as resolved or false positive3458. get_settlement_health — settlement delays, pending amounts3469. get_bank_scorecard — bank performance, latency, success rates34710. get_mdr_revenue — MDR revenue earned vs lost to failures34811. get_raast_metrics — Raast volume, ISO20022 errors34912. get_payment_mix_trend — card vs wallet vs Raast share shift35013. get_gmv_growth — volume growth by category, city, bank, segment35114. get_bank_market_share — acquiring bank volume share and rank35215. get_segment_analysis — enterprise vs SMB performance35316. get_industry_benchmarks — SBP industry benchmark comparison354 355Return format — exactly this, nothing else:356{"tool_name": "tool_name_here", "args": {}}357 358If the query is general conversation with no data need, return:359{"tool_name": "NONE", "args": {}}"""360 361_SYNTHESIZER_PROMPT = """You are a fintech operations analyst. You are given RAW DATABASE OUTPUT below. Write a summary for the user.362 363CRITICAL GROUNDING RULES:3641. You may ONLY use numbers, names, and facts that explicitly appear in the RAW DATABASE OUTPUT.3652. NEVER invent a PKR amount, percentage, merchant name, or bank name.3663. If the RAW DATABASE OUTPUT is empty, null, or says "error", say "I couldn't retrieve data for that."3674. NEVER forecast future numbers unless the RAW DATABASE OUTPUT explicitly contains a forecast section.368 369FORMAT RULES:3705. First sentence = key number or fact. No lead-in.3716. PKR format: PKR 1.2M, PKR 45K, PKR 2,3403727. Percentages with arrows: ↑ 12%, ↓ 67%, → stable3738. Structure: finding, then impact, then action. No "FINDING:"/"IMPACT:"/"ACTION:" labels.3749. Max 15 lines unless asked for detail.37510. TABLE FORMAT mandatory for comparing 3+ items. Use Markdown pipe syntax (| Col | Col |). NEVER use HTML <table> tags.37611. Never: "based on my analysis", "it appears", filler, or "[Confidence: X]"37712. Status indicators: 🔴 🟡 🟢 only37813. Always include PKR revenue impact when data supports it.37914. For bank outages: state ONLY what the data shows.38015. When data lacks detail level requested, say "Tool data doesn't include [X]-level breakdown"."""381 382 383_TOOL_STAGE_LABELS = {384 "get_failure_summary": "failure data",385 "search_issues": "flagged issues",386 "aggregate_stats": "statistics",387 "get_merchant_detail": "merchant details",388 "get_merchant_trend": "merchant trend",389 "list_merchants": "merchant list",390 "mark_resolved": "updating status",391 "get_settlement_health": "settlement data",392 "get_bank_scorecard": "bank scorecard",393 "get_mdr_revenue": "MDR revenue data",394 "get_raast_metrics": "Raast metrics",395 "get_payment_mix_trend": "payment mix data",396 "get_gmv_growth": "GMV data",397 "get_bank_market_share": "market share data",398 "get_segment_analysis": "segment data",399 "get_industry_benchmarks": "industry benchmarks",400}401 402def _get_next_best_actions(tool_called: str) -> list:403 """Generate contextual next-step prompts based on tool used and live system state."""404 from app.pipeline import get_snapshot405 406 suggestions = []407 snap = get_snapshot()408 409 # PRIORITY 1: If there is an active simulator event (e.g., Bank Outage), ALWAYS suggest it first410 active_events = snap.get("active_events", [])411 outage_event = next((e for e in active_events if e["type"] == "bank_outage"), None)412 if outage_event:413 bank = outage_event["params"].get("bank", "a partner bank")414 suggestions.append(f"What is the {bank} outage impact?")415 416 # PRIORITY 2: Map suggestions based on the tool just executed417 TOOL_SUGGESTIONS = {418 "NONE": [419 "Which bank has the most failures?",420 "Show me critical issues",421 "What is the MDR revenue loss?"422 ],423 "get_failure_summary": [424 "Break down HBL failures by reason",425 "What is the MDR impact of these failures?",426 "Show bank performance scorecard"427 ],428 "search_issues": [429 "Aggregate issues by city",430 "Show failure reasons for these merchants",431 "What is the total revenue at risk?"432 ],433 "get_merchant_trend": [434 "Show settlement health for this merchant",435 "Are there other issues in this city?",436 "What is the overall MDR trend?"437 ],438 "get_bank_scorecard": [439 "Compare bank market shares",440 "Show Raast adoption metrics",441 "Which merchants are affected by timeouts?"442 ],443 "get_mdr_revenue": [444 "Show failure reasons causing MDR loss",445 "Compare MDR by merchant segment",446 "Show settlement delays"447 ],448 "get_settlement_health": [449 "Show MDR revenue analysis",450 "Which banks have the most pending settlements?",451 "Show bank scorecard"452 ],453 "get_raast_metrics": [454 "Show payment mix shift trends",455 "Compare Raast vs card volume",456 "Show ISO20022 error breakdown"457 ],458 "get_payment_mix_trend": [459 "Show GMV growth by category",460 "Which merchants are adopting Raast?",461 "Show bank market share"462 ],463 "get_gmv_growth": [464 "Show payment method mix shift",465 "Break down by merchant segment",466 "Show industry benchmarks"467 ],468 "get_bank_market_share": [469 "Show bank scorecard for latency",470 "Show GMV growth by city",471 "Which bank is losing share?"472 ],473 "get_segment_analysis": [474 "Show enterprise vs SMB failures",475 "What is the MDR revenue by segment?",476 "Show GMV growth trends"477 ],478 "aggregate_stats": [479 "Show critical issues list",480 "Break down by failure reason",481 "Show total revenue at risk"482 ],483 "get_industry_benchmarks": [484 "Show our Raast share vs industry",485 "Show our bank success rates vs industry",486 "Show GMV growth vs industry"487 ],488 "list_merchants": [489 "Show failures for these merchants",490 "Aggregate stats by city",491 "Show merchant segments"492 ],493 "get_merchant_detail": [494 "Show trend analysis for this merchant",495 "Show failure breakdown",496 "Are there other issues in this city?"497 ],498 "mark_resolved": [499 "Show remaining critical issues",500 "What is the updated revenue at risk?",501 "Show overall failure summary"502 ]503 }504 505 # Add tool-specific suggestions506 base_suggestions = TOOL_SUGGESTIONS.get(tool_called, TOOL_SUGGESTIONS["NONE"])507 for s in base_suggestions:508 if s not in suggestions: # Prevent duplicates if outage matched a base suggestion509 suggestions.append(s)510 511 return suggestions[:3] # Return max 3 pills512 513def _stream_agentic_inner(message: str, conversation_id: str = None):514 """Streaming 3-tier agentic flow with intermediate stage events for UX."""515 if not _clients:516 yield f"data: {json.dumps({'type': 'error', 'message': 'Chat unavailable — no LLM providers configured.'})}\n\n"517 return518 519 if not conversation_id:520 conversation_id = str(uuid.uuid4())[:8]521 522 if conversation_id not in _conversations:523 _conversations[conversation_id] = []524 525 history = _conversations[conversation_id]526 history.append({"role": "user", "content": message})527 528 if len(history) > MAX_HISTORY:529 history = history[:2] + history[-(MAX_HISTORY - 2):]530 _conversations[conversation_id] = history531 532 tools_called = []533 534 # ── TIER 1: ROUTE ──535 yield f"data: {json.dumps({'type': 'stage', 'message': 'Analyzing your query...'})}\n\n"536 # Pass last exchange to router for better follow-up classification537 # e.g. "What about Karachi?" after "show failures by city"538 router_message = message539 if len(history) >= 3:540 for msg in reversed(history[:-1]):541 if msg.get("role") == "assistant" and msg.get("content"):542 last_ans = msg["content"][:150].strip()543 if last_ans:544 router_message = f"[Context: {last_ans}]\nUser: {message}"545 break546 route = _route_intent(router_message)547 tool_name = route.get("tool_name", "NONE")548 549 # ── NO TOOL MATCHED → direct redirect ──550 if tool_name == "NONE" or tool_name not in TOOL_MAP:551 answer = "I can help with failures, settlements, bank performance, and merchant trends. Try asking 'Which bank has the most failures?'"552 history.append({"role": "assistant", "content": answer})553 yield f"data: {json.dumps({'type': 'token', 'content': answer})}\n\n"554 yield f"data: {json.dumps({'type': 'done', 'conversation_id': conversation_id, 'tools_called': []})}\n\n"555 return556 557 # ── TIER 2: EXECUTE ──558 stage_label = _TOOL_STAGE_LABELS.get(tool_name, tool_name.replace("_", " "))559 yield f"data: {json.dumps({'type': 'stage', 'message': f'Fetching {stage_label}...'})}\n\n"560 561 tool_result, tools_called_list, actual_tool = _execute_single_tool(tool_name, message)562 tools_called = tools_called_list563 564 if actual_tool != "NONE":565 yield f"data: {json.dumps({'type': 'tool', 'name': actual_tool})}\n\n"566 567 if (568 actual_tool == "get_merchant_trend"569 and isinstance(tool_result, dict)570 and "error" not in tool_result571 ):572 _trend_artifacts[conversation_id] = json.loads(573 json.dumps(tool_result, default=str)574 )575 576 # ── ARTIFACT ──577 artifact = _trend_artifacts.pop(conversation_id, None)578 if artifact:579 yield f"data: {json.dumps({'type': 'artifact', 'kind': 'merchant_trend', 'data': artifact})}\n\n"580 581 # ── TIER 3: STREAM SYNTHESIZER ──582 result_str = json.dumps(tool_result, default=str, ensure_ascii=False)583 584 if actual_tool == "get_merchant_trend" and "error" not in result_str:585 result_str += "\n\n[VISUAL DISPLAYED: A trend card with all tables and forecast is shown to the user. Write only 2-3 lines: key takeaway, PKR business impact, one recommended action. No tables, no data repetition.]"586 587 if len(result_str) > 6000:588 result_str = result_str[:6000] + '"_truncated": true}'589 590 synth_messages = [591 {"role": "system", "content": _SYNTHESIZER_PROMPT},592 {593 "role": "user",594 "content": f"User Query: {message}\n\nRAW DATABASE OUTPUT:\n{result_str}",595 },596 ]597 598 raw_results = (599 [tool_result] if tool_result and isinstance(tool_result, dict) else []600 )601 _tool_results_cache[conversation_id] = raw_results602 603 full_answer = ""604 try:605 stream = _call_llm(606 messages=synth_messages, stream=True, provider="mistral", max_tokens=2048607 )608 for chunk in stream:609 if chunk.choices and chunk.choices[0].delta.content:610 token_text = chunk.choices[0].delta.content611 612 # Architecture-enforced Markdown: Convert LLM HTML table habit to GFM pipes613 token_text = token_text.replace("<table>", "").replace("</table>", "")614 token_text = token_text.replace("<thead>", "").replace("</thead>", "\n|---|---|---|---|\n")615 token_text = token_text.replace("<tbody>", "").replace("</tbody>", "")616 token_text = token_text.replace("<tr>", "").replace("</tr>", "\n")617 token_text = token_text.replace("<th>", "| ").replace("</th>", " |")618 token_text = token_text.replace("<td>", "| ").replace("</td>", " |")619 620 full_answer += token_text621 yield f"data: {json.dumps({'type': 'token', 'content': token_text})}\n\n"622 except Exception:623 full_answer = "Unable to generate a response. Please try rephrasing your question."624 yield f"data: {json.dumps({'type': 'token', 'content': full_answer})}\n\n"625 626 627 history.append({"role": "assistant", "content": full_answer})628 _conversations[conversation_id] = _compact_history(history)629 630 # ── NEXT BEST ACTIONS ──631 actual_tool_called = tools_called[0] if tools_called else "NONE"632 next_actions = _get_next_best_actions(actual_tool_called)633 if next_actions:634 yield f"data: {json.dumps({'type': 'suggestions', 'prompts': next_actions})}\n\n"635 636 yield f"data: {json.dumps({'type': 'done', 'conversation_id': conversation_id, 'tools_called': tools_called})}\n\n"637 638 639import re640 641_DATA_PATTERNS = [642 r'PKR\s*[\d,.]+[KM]?',643 r'\d+\.?\d*%\s*(failure|success|decline|drop|increase|recovery)',644 r'\d+\s+(merchants?|transactions?|issues?|failures?)',645 r'\|\s*.+\|.*\|',646]647 648def _looks_like_data_answer(text: str) -> bool:649 """Detect if response contains specific data that should come from a tool."""650 # Any PKR with a number (PKR 1.2M, PKR 1,608, PKR 0)651 if re.search(r'PKR\s*[\d,.]+\s*[KM]?', text, re.IGNORECASE):652 return True653 654 # Any percentage (12%, 7.5%)655 if re.search(r'\d+\.?\d*%', text):656 return True657 658 # Any count with commas (5,503 failures, 1,296 merchants)659 if re.search(r'[\d,]+\s+(merchants?|transactions?|issues?|failures?|banks?|cities?|errors?|timeouts?|outages?)', text, re.IGNORECASE):660 return True661 662 # Any table663 if re.search(r'\|\s*.+\|.*\|', text):664 return True665 666 return False667 668 669_SAFE_PHRASES = {670 "Bank Alfalah", "Bank of Punjab",671 "Action Plan", "Next Update", "False Positives", "Status Update",672 "Payment Method", "Failure Reason", "Success Rate", "Failure Rate",673 "Daily Volume", "Total Volume", "Average Ticket",674 "Share Change", "Growth Rate", "Transaction Volume",675 "Previous Period", "Current Period", "Market Intelligence",676 "Merchant Risk", "Raast Adoption",677}678 679_NAME_PATTERN = re.compile(r'\b([A-Z][a-z]+(?:[-\s][A-Z][a-z]+|[A-Z][a-z]+){1,4})\b')680 681 682def _validate_response(answer: str, tool_results: list) -> str:683 """Hard constraint: every named entity in the response must exist in tool data."""684 # Strip markdown tables before checking — table headers are not entity names685 answer_no_tables = re.sub(r'\|.+\|.*\n?', '', answer)686 687 mentioned = set(_NAME_PATTERN.findall(answer_no_tables))688 mentioned -= _SAFE_PHRASES689 690 if not mentioned:691 return answer692 693 # Build verified set from tool results694 verified = set()695 name_keys = ("merchant_name", "name", "bank", "city", "category", "segment")696 for result in tool_results:697 if not isinstance(result, dict):698 continue699 if "error" in result:700 continue701 for key in name_keys:702 val = result.get(key)703 if val and isinstance(val, str):704 verified.add(val)705 for v in result.values():706 if isinstance(v, list):707 for item in v:708 if isinstance(item, dict):709 for key in name_keys:710 val = item.get(key)711 if val and isinstance(val, str):712 verified.add(val)713 714 fabricated = mentioned - verified715 if not fabricated:716 return answer717 718 print(f"VALIDATOR: blocked {len(fabricated)} fabricated names: {fabricated}")719 720 # Build safe summary from actual tool data721 safe_parts = []722 for result in tool_results:723 if not isinstance(result, dict) or "error" in result:724 continue725 try:726 if "total_failures" in result:727 safe_parts.append(f"Total failures in period: {result['total_failures']}")728 if "breakdown" in result and isinstance(result["breakdown"], list):729 for b in result["breakdown"][:3]:730 reason = b.get("failure_reason", b.get("reason", "unknown"))731 cnt = b.get("cnt", b.get("count", 0))732 amt = b.get("total_amount", b.get("failed_amount", 0))733 safe_parts.append(f"{reason}: {cnt} failures, PKR {amt:,.0f}")734 if "by_bank" in result and isinstance(result["by_bank"], dict):735 for bank, entries in list(result["by_bank"].items())[:4]:736 if isinstance(entries, list):737 total = sum(r.get("count", r.get("cnt", 0)) for r in entries)738 safe_parts.append(f"{bank}: {total} failures")739 elif isinstance(entries, dict):740 safe_parts.append(f"{bank}: {entries.get('count', entries.get('cnt', 'N/A'))} failures")741 if "banks" in result and isinstance(result["banks"], list):742 for b in result["banks"][:4]:743 bank = b.get("bank", "unknown")744 failures = b.get("failure_count", 0)745 rate = b.get("failure_rate", 0)746 safe_parts.append(f"{bank}: {failures} failures ({rate:.1%} rate)")747 if "period" in result:748 safe_parts.insert(0, f"Period: {result['period']}")749 except Exception:750 continue751 752 if safe_parts:753 return "Tool data (no merchant-level breakdown available):\n" + "\n".join(f"• {p}" for p in safe_parts)754 755 return "The available data doesn't include the detail level requested. Try asking at the bank or category level."756 757 758def _compact_history(history: list):759 """Compact history but preserve question context and last tool result."""760 if len(history) <= 8:761 return history762 763 compacted = [history[0]] # First user message764 765 # Find last user message before the final exchange (for context)766 last_user_idx = None767 for i in range(len(history) - 3, 0, -1):768 if history[i].get("role") == "user":769 last_user_idx = i770 break771 if last_user_idx:772 compacted.append(history[last_user_idx])773 774 # Find last tool result and its preceding assistant message775 last_tool_idx = None776 for i in range(len(history) - 1, -1, -1):777 if history[i].get("role") == "tool":778 last_tool_idx = i779 break780 781 if last_tool_idx and last_tool_idx > 0:782 prev = history[last_tool_idx - 1]783 if prev.get("role") == "assistant" and "tool_calls" in prev:784 tool_msg = json.dumps(prev, default=str)785 if len(tool_msg) < 2000:786 compacted.append(prev)787 compacted.append(history[last_tool_idx])788 789 # Always keep last 2 messages (current exchange)790 compacted.extend(history[-2:])791 return compacted792 793MAX_TOOL_ROUNDS = 3794 795# ── Category-based tool routing ──────────────────────────────────────────796 797TOOL_CATEGORIES = {798 "ops": {0, 1, 2, 3, 4, 5, 6},799 "financial": {7, 8, 9},800 "market": {10, 11, 12, 13, 14},801 "benchmarks": {15},802}803 804_TOOL_NAME_TO_INDEX = {t["function"]["name"]: i for i, t in enumerate(TOOL_DEFINITIONS)}805 806_INDEX_TO_CATEGORY = {}807for cat, indices in TOOL_CATEGORIES.items():808 for i in indices:809 _INDEX_TO_CATEGORY[i] = cat810 811 812_TOOL_TRIGGERS = {813 "get_failure_summary": [814 "failure", "failures", "failed", "timeout", "timeouts",815 "declined", "decline", "error rate", "failure rate", "failure reason",816 "insufficient", "expired card", "invalid card", "daily limit",817 ],818 "search_issues": ["issues", "flagged", "closure", "closures", "anomal"],819 "aggregate_stats": ["how many", "total count", "overall summary"],820 "get_merchant_detail": ["tell me about", "details of", "contact", "phone number"],821 "get_merchant_trend": [822 "recovering", "trend", "forecast", "change point",823 "improving", "worsening", "how is", "doing",824 ],825 "list_merchants": ["list merchants", "show merchants", "find merchants", "all merchants"],826 "mark_resolved": ["resolved", "false positive", "not an issue", "fixed"],827 "get_settlement_health": ["settlement", "stuck", "pending settlement"],828 "get_bank_scorecard": [829 "bank scorecard", "bank performance", "bank success rate",830 "compare bank", "bank vs", "bank latency",831 ],832 "get_mdr_revenue": ["mdr", "merchant discount rate"],833 "get_raast_metrics": ["raast", "iso20022", "ibft"],834 "get_payment_mix_trend": [835 "payment mix", "method mix", "card vs", "wallet vs",836 "mix shift", "method share", "payment method share",837 ],838 "get_gmv_growth": ["gmv", "gross merchandise", "volume growth"],839 "get_bank_market_share": ["market share", "bank share", "bank competition"],840 "get_segment_analysis": ["segment", "enterprise vs", "merchant segment"],841 "get_industry_benchmarks": ["industry", "sbp", "benchmark", "compare to industry"],842}843 844 845def _select_tools(message: str) -> list:846 """Pre-select tools based on keywords. Reduces schema size to avoid 400 errors."""847 msg_lower = message.lower()848 matched = []849 850 for tool_name, keywords in _TOOL_TRIGGERS.items():851 for kw in keywords:852 if kw in msg_lower:853 matched.append(tool_name)854 break855 856 if not matched:857 return TOOL_DEFINITIONS # No match → send all (safe default)858 859 seen = set()860 unique = []861 for name in matched:862 if name not in seen:863 seen.add(name)864 unique.append(name)865 866 selected = unique[:5]867 return [t for t in TOOL_DEFINITIONS if t["function"]["name"] in selected]868 869 870def _call_llm(messages, tools=None, tool_choice=None, stream=False, model=None, provider=None, max_tokens=None):871 """Try each provider in order. Fall back on 429 or 500+ errors."""872 global _active_provider873 last_error = None874 875 # ← NEW: Build provider order — pinned provider first, then others as fallback876 order = list(range(len(_clients)))877 if provider:878 for i, cfg in enumerate(_client_configs):879 if cfg["name"] == provider:880 order = [i] + [j for j in order if j != i]881 break882 883 for i in order:884 client = _clients[i]885 cfg = _client_configs[i] # ← MODIFIED: use _client_configs for correct mapping886 prov = cfg["name"]887 used_model = model or cfg["model"] # ← NEW: allow model override888 889 kwargs = {890 "model": used_model,891 "messages": messages,892 "temperature": 0.1,893 "max_tokens": max_tokens or 1024, # ← MODIFIED: allow max_tokens override894 }895 if tools:896 kwargs["tools"] = tools897 if tool_choice:898 kwargs["tool_choice"] = tool_choice899 if stream:900 kwargs["stream"] = True901 902 try:903 response = client.chat.completions.create(**kwargs)904 _active_provider = prov905 return response906 except Exception as e:907 err_str = str(e)908 is_rate_limit = "429" in err_str or "rate_limit" in err_str909 is_server = any(code in err_str for code in ["500", "502", "503", "504"])910 is_bad_request = "400" in err_str911 if is_rate_limit or is_server or is_bad_request:912 print(f"LLM [{prov}] {err_str[:80]}... trying next")913 last_error = e914 continue915 raise916 917 raise last_error or Exception("No LLM providers available")918 919 920# ═══════════════════════════════════════════════════════════════════════════921# ← NEW: 3-TIER AGENTIC FLOW922# ═══════════════════════════════════════════════════════════════════════════923 924def _extract_json(text: str) -> dict:925 """Extract first valid JSON object from text. Handles Qwen3 think tags, markdown, nested braces."""926 # Strip Qwen 3 thinking tags927 text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()928 929 # Strip markdown code blocks930 if "```" in text:931 blocks = re.findall(r'```(?:json)?\s*(.*?)```', text, re.DOTALL)932 if blocks:933 text = blocks[0].strip()934 935 # Try direct parse936 try:937 return json.loads(text)938 except json.JSONDecodeError:939 pass940 941 # Brace-counting extraction — finds outermost { ... }942 start = text.find('{')943 if start < 0:944 return {}945 depth = 0946 end = -1947 for i in range(start, len(text)):948 if text[i] == '{':949 depth += 1950 elif text[i] == '}':951 depth -= 1952 if depth == 0:953 end = i954 break955 if end < 0:956 return {}957 try:958 return json.loads(text[start:end + 1])959 except json.JSONDecodeError:960 return {}961 962 963def _route_intent(message: str) -> dict:964 """Tier 1: Classify intent and pick one tool. Uses Cerebras (no tool schema)."""965 try:966 response = _call_llm(967 messages=[968 {"role": "system", "content": _ROUTER_SYSTEM_PROMPT},969 {"role": "user", "content": message},970 ],971 tools=None,972 provider="groq",973 )974 content = response.choices[0].message.content or "{}"975 return _extract_json(content)976 except Exception as e:977 print(f"ROUTER FAILED: {e}")978 return {"tool_name": "NONE", "args": {}}979 980 981def _execute_single_tool(tool_name: str, message: str) -> tuple:982 """Tier 2: Ask LLM to format args for ONE tool, then execute it. Uses Groq."""983 if tool_name not in TOOL_MAP:984 return None, [], "NONE"985 986 tool_schema = next(987 (t for t in TOOL_DEFINITIONS if t["function"]["name"] == tool_name), None988 )989 if not tool_schema:990 return None, [], "NONE"991 992 try:993 response = _call_llm(994 messages=[995 {996 "role": "system",997 "content": (998 "You are a parameter extraction agent for a Pakistani payment gateway dashboard. "999 "Extract ONLY the parameters explicitly mentioned in the user query. "1000 "Omit any parameter not clearly stated. "1001 "For bank names use: HBL, Meezan, Bank Alfalah, UBL, HMB, Bank of Punjab. "1002 "For cities use exact names: Karachi, Lahore, Islamabad, Faisalabad, Rawalpindi. "1003 "For merchant categories use: electronics, pharmacy, supermarket, clothing, restaurant, mobile, medical, general_store. "1004 "days should be an integer. limit should be an integer. merchant_id should be an integer."1005 ),1006 },1007 {"role": "user", "content": message},1008 ],1009 tools=[tool_schema],1010 tool_choice={"type": "function", "function": {"name": tool_name}},1011 provider="groq",1012 )1013 1014 msg = response.choices[0].message1015 1016 # Guard: fallback provider returned text instead of tool call1017 if not msg.tool_calls:1018 print(f"EXECUTOR: no tool_calls for {tool_name}, executing with defaults")1019 fn = TOOL_MAP[tool_name]1020 result = fn()1021 return result, [tool_name], tool_name1022 1023 tool_call = msg.tool_calls[0]1024 fn_args = json.loads(tool_call.function.arguments)1025 fn_args = {k: v for k, v in fn_args.items() if v is not None}1026 1027 # Coerce integer fields (same logic as _run_tool_phase)1028 for _int_key in ("days", "limit", "merchant_id"):1029 if _int_key in fn_args and isinstance(fn_args[_int_key], str):1030 try:1031 fn_args[_int_key] = int(fn_args[_int_key])1032 except (ValueError, TypeError):1033 del fn_args[_int_key]1034 if "limit" in fn_args and isinstance(fn_args["limit"], int):1035 fn_args["limit"] = min(fn_args["limit"], 50)1036 1037 # After the existing int coercion block, add:1038 # Remove any days value that could not be parsed as int1039 if "days" in fn_args and not isinstance(fn_args["days"], int):1040 del fn_args["days"]1041 1042 # Execute the actual Python function1043 fn = TOOL_MAP[tool_name]1044 result = fn(**fn_args)1045 1046 return result, [tool_name], tool_name1047 1048 except Exception as e:1049 print(f"EXECUTOR FAILED for {tool_name}: {e}")1050 error_result = {"error": f"Failed to execute {tool_name}: {str(e)}"}1051 return error_result, [tool_name], tool_name1052 1053 1054def _run_agentic_phase(message: str, conversation_id: str = None):1055 """1056 3-tier agentic flow: Router → Executor → Synthesizer.1057 Returns same 8-tuple signature as _run_tool_phase.1058 When a tool is matched, returns needs_final=True with api_messages1059 set to the Synthesizer prompt — callers stream/call it as usual.1060 """1061 if not _clients:1062 return [], [], [], None, conversation_id or "", False, "Chat unavailable — no LLM providers configured."1063 1064 if not conversation_id:1065 conversation_id = str(uuid.uuid4())[:8]1066 1067 if conversation_id not in _conversations:1068 _conversations[conversation_id] = []1069 1070 history = _conversations[conversation_id]1071 history.append({"role": "user", "content": message})1072 1073 if len(history) > MAX_HISTORY:1074 history = history[:2] + history[-(MAX_HISTORY - 2):]1075 _conversations[conversation_id] = history1076 1077 events = []1078 tools_called = []1079 1080 # ── TIER 1: ROUTE ──1081 route = _route_intent(message)1082 tool_name = route.get("tool_name", "NONE")1083 1084 # ── TIER 2: EXECUTE ──1085 tool_result = None1086 actual_tool = "NONE"1087 1088 if tool_name != "NONE" and tool_name in TOOL_MAP:1089 tool_result, tools_called, actual_tool = _execute_single_tool(tool_name, message)1090 1091 if actual_tool != "NONE":1092 events.append({"type": "tool", "name": actual_tool})1093 1094 # Store trend artifact (same pattern as _run_tool_phase)1095 if (1096 actual_tool == "get_merchant_trend"1097 and isinstance(tool_result, dict)1098 and "error" not in tool_result1099 ):1100 _trend_artifacts[conversation_id] = json.loads(1101 json.dumps(tool_result, default=str)1102 )1103 1104 # ── TIER 3: PREPARE SYNTHESIZER (caller will stream it) ──1105 if actual_tool == "NONE":1106 # No tool matched — return conversational redirect as direct answer1107 answer = "I can help with failures, settlements, bank performance, and merchant trends. Try asking 'Which bank has the most failures?'"1108 history.append({"role": "assistant", "content": answer})1109 return events, history, tools_called, None, conversation_id, False, answer1110 1111 # Build raw result string for Synthesizer1112 result_str = json.dumps(tool_result, default=str, ensure_ascii=False)1113 1114 # Append trend visual instruction (same pattern as _run_tool_phase)1115 if actual_tool == "get_merchant_trend" and "error" not in result_str:1116 result_str += "\n\n[VISUAL DISPLAYED: A trend card with all tables and forecast is shown to the user. Write only 2-3 lines: key takeaway, PKR business impact, one recommended action. No tables, no data repetition.]"1117 1118 if len(result_str) > 6000:1119 result_str = result_str[:6000] + '"_truncated": true}'1120 1121 # Synthesizer messages — NO tools, pure text generation on Mistral1122 # ── Build context from recent conversation history ───────────────1123 # Helps synthesizer understand follow-up questions1124 # e.g. "What about Meezan?" after asking about bank failures1125 prev_context = ""1126 if len(history) >= 4:1127 # Find last assistant answer (before current exchange)1128 for msg in reversed(history[:-2]):1129 if msg.get("role") == "assistant" and msg.get("content"):1130 prev_text = msg["content"][:250].strip()1131 if prev_text:1132 prev_context = f"\n\nPrevious answer (for follow-up context): {prev_text}"1133 break1134 1135 synth_messages = [1136 {"role": "system", "content": _SYNTHESIZER_PROMPT},1137 {1138 "role": "user",1139 "content": (1140 f"User Query: {message}"1141 f"{prev_context}"1142 f"\n\nRAW DATABASE OUTPUT:\n{result_str}"1143 ),1144 },1145 ]1146 1147 # Store raw results for post-synthesis validation by callers1148 raw_results = (1149 [tool_result] if tool_result and isinstance(tool_result, dict) else []1150 )1151 _tool_results_cache[conversation_id] = raw_results1152 1153 # Return needs_final=True — callers will stream/call the Synthesizer1154 return (1155 events,1156 history,1157 tools_called,1158 synth_messages,1159 conversation_id,1160 True,1161 None,1162 )1163 1164 1165# ═══════════════════════════════════════════════════════════════════════════1166# EXISTING: _run_tool_phase (UNCHANGED)1167# ═══════════════════════════════════════════════════════════════════════════1168 1169def _run_tool_phase(message: str, conversation_id: str = None):1170 """1171 Non-streaming tool loop.1172 Returns: (events, history, tools_called, final_api_messages, conv_id, needs_final_call, direct_answer)1173 """1174 if not _clients:1175 return [], [], [], None, conversation_id or "", False, "Chat unavailable — no LLM providers configured."1176 1177 if not conversation_id:1178 conversation_id = str(uuid.uuid4())[:8]1179 1180 if conversation_id not in _conversations:1181 _conversations[conversation_id] = []1182 1183 history = _conversations[conversation_id]1184 history.append({"role": "user", "content": message})1185 1186 if len(history) > MAX_HISTORY:1187 history = history[:2] + history[-(MAX_HISTORY - 2):]1188 _conversations[conversation_id] = history1189 1190 tools_called = []1191 events = []1192 _tool_results_raw = []1193 api_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + history1194 active_tools = _select_tools(message)1195 1196 for round_num in range(MAX_TOOL_ROUNDS):1197 try:1198 _msg_str = " ".join(m.get("content", "") or "" for m in api_messages)1199 _est_tokens = len(_msg_str) // 31200 _tools = active_tools if _est_tokens < 10000 else None