CoolFace
Apppublic

mrik8899/fintech-demo

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
chat.py1471 linesDownload Raw Back to app
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

Showing the first 1,200 of 1471 lines. Download the file for the rest.