Your LLM shouldn't be handling if/else logic. Here's a smarter way. A developer introduced airules, a Python library that adds static typing and rule-based logic to decision-making systems, reducing reliance on LLMs for deterministic cases. The library uses typed facts and predicate builders to replace fragile if/elif chains, catching errors at compile time and reserving LLMs for ambiguous edge cases. LLMs are remarkably good at handling the unknown. Give them an edge case you've never anticipated, and they'll reason through it correctly. That's genuine value. The problem is that most of your traffic isn't unknown. A payment that failed on the third retry, a VIP customer with a disputed charge, an order flagged at 0.9 risk score — you already know what to do with these. But if your architecture routes everything through the LLM, it pays full inference cost regardless: python from pydantic ai import Agent from enum import Enum class Action Enum : FLAG FRAUD = "flag fraud" OPEN DISPUTE = "open dispute" SCHEDULE RETRY = "schedule retry" ALERT ACCOUNT MANAGER = "alert account manager" STANDARD PROCESSING = "standard processing" agent = Agent "openai:gpt-4o-mini", output type=Action, system prompt="Decide what action to take on this order event.", async def handle event: dict - Action: result = await agent.run str event return result.output This works. It also pays LLM prices for decisions that — in a large portion of cases — are already fully determined by the input data. The obvious fix — an if/elif pre-filter — creates a different problem. It works until you have 15 conditions, three engineers with conflicting opinions, a silent ordering bug, and tests that only verify outputs, not the logic structure itself. The spaghetti grows fast. What you actually need is a layer that: That's exactly what airules https://ai-rules-eight.vercel.app/ is built for. By the end of this article you'll have a working hybrid system that reserves the LLM for what it's actually good at — and handles everything else for free. The problem with raw if/elif chains isn't just readability. event "risk score" is untyped — a missing key silently raises KeyError at runtime, a misspelled field name goes unnoticed until production, and nothing stops you from accidentally comparing a float to a string. airules brings static typing to decision logic. Your IDE catches bad field references. Pyright flags type mismatches. The rule set becomes something a tool can inspect, not just a human can read. python from typing import Literal from airules import Fact, Field, NumberField OrderStatus = Literal "placed", "payment failed", "fulfilled", "disputed", "refunded" CustomerTier = Literal "standard", "vip", "wholesale" class OrderEvent Fact : status: Field OrderStatus amount usd: NumberField float customer tier: Field CustomerTier retry count: NumberField int risk score: NumberField float 0.0–1.0 from fraud detection service Fact is not a Pydantic model or a dataclass — it's a purpose-built descriptor system where each field type NumberField , Field , ListField doubles as a predicate builder . That's what lets you write OrderEvent.risk score.ge 0.85 as a first-class object rather than an inline comparison that lives and dies in one function. python from airules import KnowledgeEngine, Rule, Default class OrderRouter KnowledgeEngine OrderEvent, Action : High risk score is an unconditional block — checked first @Rule OrderEvent.risk score.ge 0.85 def high risk self, event: OrderEvent - Action: return Action.FLAG FRAUD A disputed charge always opens a case, regardless of other signals @Rule OrderEvent.status.eq "disputed" def dispute self, event: OrderEvent - Action: return Action.OPEN DISPUTE Failed payment with retries remaining → schedule another attempt @Rule OrderEvent.status.eq "payment failed" & OrderEvent.retry count.lt 3 def retry self, event: OrderEvent - Action: return Action.SCHEDULE RETRY VIP customer exhausted retries → human touch, not automation @Rule OrderEvent.status.eq "payment failed" & OrderEvent.customer tier.eq "vip" & OrderEvent.retry count.ge 3 def vip payment exhausted self, event: OrderEvent - Action: return Action.ALERT ACCOUNT MANAGER Fires only when nothing above matched @Default def fallback self, event: OrderEvent - Action: return Action.STANDARD PROCESSING Rules evaluate top-to-bottom . The first match wins — no fall-through, no ambiguity. @Default is always last, regardless of where you declare it. router = OrderRouter router.run OrderEvent status="payment failed", amount usd=149.00, customer tier="vip", retry count=3, risk score=0.2, → Action.ALERT ACCOUNT MANAGER matched vip payment exhausted , stopped there The engine is Generic OrderEvent, Action — run returns Action | None , fully typed. No casting, no Any , no surprises. Trade-off to know: airules uses declaration order as the default priority. If two rules can match the same input, the one declared first wins. This is explicit and predictable, but it means rule ordering is load-bearing — treat it with the same care you'd treat database index order. In the example above, high risk must come before dispute because a high-risk disputed order should be flagged for fraud, not just opened as a dispute case. If your rules live only in Python source, the rest of your system is blind to them. Your database can't query them. A code review diff shows syntax changes, not logic changes. And critically — your LLM can't reason about them. airules predicates are first-class objects that serialize to plain dicts : p = OrderEvent.status.eq "payment failed" & OrderEvent.retry count.lt 3 p.to dict { "op": "and", "operands": {"op": "eq", "field": "status", "value": "payment failed"}, {"op": "lt", "field": "retry count", "value": 3} } Round-trips cleanly restored = Predicate.from dict p.to dict One level up, describe dumps the entire rule set — every rule, its predicate, its priority: python import json print json.dumps OrderRouter.describe , indent=2 { "facts": {"name": "OrderEvent", "fields": { "status": "Field", "amount usd": "NumberField", "customer tier": "Field", "retry count": "NumberField", "risk score": "NumberField" }} , "rules": {"name": "high risk", "predicate": {...}, "priority": 5, "is default": false}, {"name": "dispute", "predicate": {...}, "priority": 4, "is default": false}, {"name": "retry", "predicate": {...}, "priority": 3, "is default": false}, {"name": "vip payment exhausted","predicate": {...}, "priority": 2, "is default": false}, {"name": "fallback", "predicate": null, "priority": 0, "is default": true} } Store it. Diff it in PRs. Feed it into a rules-editor UI. Or — and this is the key move — pass it directly into your LLM's system prompt . Incoming order event │ ▼ ┌──────────────────┐ match ┌──────────────────┐ │ OrderRouter │ ──────────▶ │ Return Action │ ← ~0ms, $0.00 │ airules │ └──────────────────┘ │ │ no match │ │ ──────────▶ ┌──────────────────┐ └──────────────────┘ │ LLM fallback │ ← ~800ms, costs tokens └──────────────────┘ Rules handle the known cases for free. The LLM handles only what genuinely falls through — unusual combinations of signals that no single rule anticipated. @Default fallback The LLM call lives inside @Default — the method that fires only when the engine found no match. Everything from sections II and III stays the same; you're replacing just the fallback method: python import json from pydantic ai import Agent from airules import Fact, Field, NumberField, KnowledgeEngine, Rule, Default class OrderRouter KnowledgeEngine OrderEvent, Action : @Rule OrderEvent.risk score.ge 0.85 def high risk self, event: OrderEvent - Action: return Action.FLAG FRAUD @Rule OrderEvent.status.eq "disputed" def dispute self, event: OrderEvent - Action: return Action.OPEN DISPUTE @Rule OrderEvent.status.eq "payment failed" & OrderEvent.retry count.lt 3 def retry self, event: OrderEvent - Action: return Action.SCHEDULE RETRY @Rule OrderEvent.status.eq "payment failed" & OrderEvent.customer tier.eq "vip" & OrderEvent.retry count.ge 3 def vip payment exhausted self, event: OrderEvent - Action: return Action.ALERT ACCOUNT MANAGER @Default async def llm triage self, event: OrderEvent - Action: Only reached for combinations no rule anticipated — e.g. a wholesale customer with moderate risk 0.6 and a fulfilled order that was just disputed for the first time. rules schema = json.dumps type self .describe , indent=2 agent = Agent "openai-chat:gpt-5.4-mini",, output type=Action, system prompt= "You are an order event classifier. " "The rules below already handle known cases deterministically — " "you only receive events that matched none of them. " "Decide the best action for this edge case.\n\n" f"Existing rules:\n{rules schema}" , result = await agent.run str event return result.output router = OrderRouter action = await router.run async event type self .describe is the key line Without it, your LLM and your rules engine are two separate systems with no shared understanding. The model might return SCHEDULE RETRY for an event that should have been caught by the high risk rule — creating inconsistencies that are maddening to debug. With it , the LLM receives a precise, machine-readable description of every rule that already exists. It knows exactly which cases are handled upstream, so it can't contradict them — and it focuses only on the genuine gap. The model isn't duplicating your rules. It's extending them. run async and evaluate async handle async @Default methods automatically. Your synchronous @Rule methods don't need to change. Once this is running, the number to watch isn't "how many events did the engine handle." It's "what percentage hit @Default?" The gap between those numbers is the payoff from getting your rules right. The engine accepts an observer that receives every evaluation result — without blocking the engine: python from airules import LoggingObserver Built-in: logs every evaluation, warns loudly on @Default hits router = OrderRouter observer=LoggingObserver await router.run async event INFO OrderRouter: rule='retry' result=