|
| 1 | +"""Router Agent — classifies query complexity for pipeline routing. |
| 2 | +
|
| 3 | +Model: llama-3.1-8b-instant via Groq (fast, cheap) |
| 4 | +Output: SIMPLE (clear single intent) or COMPLEX (ambiguous, needs deep reasoning) |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +from typing import Literal |
| 9 | + |
| 10 | +from dotenv import load_dotenv |
| 11 | +from groq import Groq, RateLimitError |
| 12 | +from pydantic import BaseModel, Field |
| 13 | +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential |
| 14 | + |
| 15 | +load_dotenv() |
| 16 | + |
| 17 | +_SYSTEM = """You are a routing agent for a banking intent classification pipeline with 77 fine-grained labels. |
| 18 | +
|
| 19 | +Your task: decide whether classifying the customer query requires SIMPLE or COMPLEX routing. |
| 20 | +
|
| 21 | +SIMPLE — one label is unambiguously correct; two experienced annotators would always agree. |
| 22 | + Examples: |
| 23 | + - "My card was eaten by the ATM" → only card_swallowed fits |
| 24 | + - "I need to change my PIN" → only change_pin fits |
| 25 | + - "Please delete my account" → only terminate_account fits |
| 26 | +
|
| 27 | +COMPLEX — two or more labels are plausible; annotators might reasonably disagree. |
| 28 | + Always route COMPLEX for these patterns: |
| 29 | + - Refund/return language → Refund_not_showing_up vs request_refund vs reverted_card_payment |
| 30 | + - Transfer timing questions → pending_transfer vs transfer_timing vs transfer_not_received |
| 31 | + - Identity/verify language → unable_to_verify_identity vs verify_my_identity vs why_verify_identity |
| 32 | + - Reluctance toward identity verification (not wanting to do it, asking if optional) → why_verify_identity vs unable_to_verify_identity |
| 33 | + - Unrecognised/strange charges → card_payment_not_recognised vs extra_charge_on_statement vs compromised_card |
| 34 | +
|
| 35 | +You MUST respond with ONLY this JSON (no markdown, no extra keys): |
| 36 | +{"complexity": "SIMPLE", "reasoning": "one sentence", "confidence": 0.9} |
| 37 | + or |
| 38 | +{"complexity": "COMPLEX", "reasoning": "one sentence", "confidence": 0.8} |
| 39 | +
|
| 40 | +The "complexity" value MUST be exactly the word SIMPLE or the word COMPLEX.""" |
| 41 | + |
| 42 | + |
| 43 | +class RouterDecision(BaseModel): |
| 44 | + route: Literal["SIMPLE", "COMPLEX"] |
| 45 | + reasoning: str = Field(description="One sentence explaining the routing decision") |
| 46 | + confidence: float = Field(ge=0.0, le=1.0) |
| 47 | + |
| 48 | + |
| 49 | +_client: Groq | None = None |
| 50 | + |
| 51 | + |
| 52 | +def _get_client() -> Groq: |
| 53 | + global _client |
| 54 | + if _client is None: |
| 55 | + _client = Groq() |
| 56 | + return _client |
| 57 | + |
| 58 | + |
| 59 | +@retry( |
| 60 | + retry=retry_if_exception_type(RateLimitError), |
| 61 | + wait=wait_exponential(multiplier=1, min=2, max=30), |
| 62 | + stop=stop_after_attempt(3), |
| 63 | +) |
| 64 | +def route(query: str) -> RouterDecision: |
| 65 | + """Route a banking query to SIMPLE or COMPLEX annotation path.""" |
| 66 | + response = _get_client().chat.completions.create( |
| 67 | + model="llama-3.1-8b-instant", |
| 68 | + messages=[ |
| 69 | + {"role": "system", "content": _SYSTEM}, |
| 70 | + {"role": "user", "content": f"Customer query: {query}"}, |
| 71 | + ], |
| 72 | + response_format={"type": "json_object"}, |
| 73 | + temperature=0, |
| 74 | + ) |
| 75 | + raw = json.loads(response.choices[0].message.content) |
| 76 | + # Model outputs "complexity" key per the prompt; map to our field name |
| 77 | + complexity = raw.get("complexity", raw.get("route", "")).upper().strip() |
| 78 | + if complexity not in ("SIMPLE", "COMPLEX"): |
| 79 | + complexity = "COMPLEX" # default to caution on parse failure |
| 80 | + return RouterDecision( |
| 81 | + route=complexity, |
| 82 | + reasoning=raw.get("reasoning", ""), |
| 83 | + confidence=float(raw.get("confidence", 0.5)), |
| 84 | + ) |
0 commit comments