Skip to content

Commit ecf0b11

Browse files
savioloboclaude
andcommitted
feat: build Router Agent with Groq llama-3.1-8b and 11 fixture tests
- routes queries to SIMPLE/COMPLEX based on annotator-disagreement risk - uses Groq SDK directly with json_object mode (avoids function-call parser bugs) - tenacity retry on RateLimitError - conftest.py loads .env so skipif checks see API keys at collection time - 11/11 tests green: 5/5 simple → SIMPLE, 5/5 ambiguous → COMPLEX Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e7323d4 commit ecf0b11

3 files changed

Lines changed: 153 additions & 0 deletions

File tree

agents/router.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
)

conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from dotenv import load_dotenv
2+
3+
load_dotenv()

tests/test_router.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Router Agent unit tests — runs against live Groq API.
2+
3+
Skipped automatically if GROQ_API_KEY is not set.
4+
"""
5+
6+
import json
7+
import os
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
requires_groq = pytest.mark.skipif(
13+
not os.getenv("GROQ_API_KEY"), reason="GROQ_API_KEY not set"
14+
)
15+
16+
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "items"
17+
18+
19+
def _load_fixtures(difficulty: str) -> list[dict]:
20+
items = []
21+
for f in sorted(FIXTURES_DIR.glob("item_*.json")):
22+
item = json.loads(f.read_text())
23+
if item["difficulty"] == difficulty:
24+
items.append(item)
25+
return items
26+
27+
28+
SIMPLE_ITEMS = _load_fixtures("simple")
29+
AMBIGUOUS_ITEMS = _load_fixtures("ambiguous")
30+
31+
32+
@requires_groq
33+
@pytest.mark.parametrize("item", SIMPLE_ITEMS, ids=[i["true_intent"] for i in SIMPLE_ITEMS])
34+
def test_simple_items_route_to_simple(item: dict) -> None:
35+
from agents.router import route
36+
37+
decision = route(item["text"])
38+
assert decision.route == "SIMPLE", (
39+
f"Expected SIMPLE for '{item['text']}'\n"
40+
f"Got {decision.route}: {decision.reasoning}"
41+
)
42+
assert 0.0 <= decision.confidence <= 1.0
43+
44+
45+
@requires_groq
46+
@pytest.mark.parametrize("item", AMBIGUOUS_ITEMS, ids=[i["true_intent"] for i in AMBIGUOUS_ITEMS])
47+
def test_ambiguous_items_route_to_complex(item: dict) -> None:
48+
from agents.router import route
49+
50+
decision = route(item["text"])
51+
assert decision.route == "COMPLEX", (
52+
f"Expected COMPLEX for '{item['text']}'\n"
53+
f"Got {decision.route}: {decision.reasoning}"
54+
)
55+
assert 0.0 <= decision.confidence <= 1.0
56+
57+
58+
@requires_groq
59+
def test_router_returns_valid_decision_structure() -> None:
60+
from agents.router import RouterDecision, route
61+
62+
decision = route("How do I change my PIN?")
63+
assert isinstance(decision, RouterDecision)
64+
assert decision.route in ("SIMPLE", "COMPLEX")
65+
assert len(decision.reasoning) > 5
66+
assert 0.0 <= decision.confidence <= 1.0

0 commit comments

Comments
 (0)