{"slug": "your-llm-shouldn-t-be-handling-if-else-logic-here-s-a-smarter-way", "title": "Your LLM shouldn't be handling if/else logic. Here's a smarter way.", "summary": "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.", "body_md": "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.\n\nThe 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:\n\n``` python\nfrom pydantic_ai import Agent\nfrom enum import Enum\n\nclass Action(Enum):\n    FLAG_FRAUD = \"flag_fraud\"\n    OPEN_DISPUTE = \"open_dispute\"\n    SCHEDULE_RETRY = \"schedule_retry\"\n    ALERT_ACCOUNT_MANAGER = \"alert_account_manager\"\n    STANDARD_PROCESSING = \"standard_processing\"\n\nagent = Agent(\n    \"openai:gpt-4o-mini\",\n    output_type=Action,\n    system_prompt=\"Decide what action to take on this order event.\",\n)\n\nasync def handle(event: dict) -> Action:\n    result = await agent.run(str(event))\n    return result.output\n```\n\nThis works. It also pays LLM prices for decisions that — in a large portion of cases — are already fully determined by the input data.\n\n**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.\n\n**What you actually need** is a layer that:\n\nThat'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.\n\nThe problem with raw `if/elif`\n\nchains isn't just readability. `event[\"risk_score\"]`\n\nis untyped — a missing key silently raises `KeyError`\n\nat runtime, a misspelled field name goes unnoticed until production, and nothing stops you from accidentally comparing a `float`\n\nto a string.\n\n`airules`\n\nbrings 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.\n\n``` python\nfrom typing import Literal\nfrom airules import Fact, Field, NumberField\n\nOrderStatus = Literal[\"placed\", \"payment_failed\", \"fulfilled\", \"disputed\", \"refunded\"]\nCustomerTier = Literal[\"standard\", \"vip\", \"wholesale\"]\n\nclass OrderEvent(Fact):\n    status: Field[OrderStatus]\n    amount_usd: NumberField[float]\n    customer_tier: Field[CustomerTier]\n    retry_count: NumberField[int]\n    risk_score: NumberField[float]  # 0.0–1.0 from fraud detection service\n```\n\n`Fact`\n\nis not a Pydantic model or a dataclass — it's a purpose-built descriptor system where each field type (`NumberField`\n\n, `Field`\n\n, `ListField`\n\n) doubles as a **predicate builder**. That's what lets you write `OrderEvent.risk_score.ge(0.85)`\n\nas a first-class object rather than an inline comparison that lives and dies in one function.\n\n``` python\nfrom airules import KnowledgeEngine, Rule, Default\n\nclass OrderRouter(KnowledgeEngine[OrderEvent, Action]):\n\n    # High risk score is an unconditional block — checked first\n    @Rule(OrderEvent.risk_score.ge(0.85))\n    def high_risk(self, event: OrderEvent) -> Action:\n        return Action.FLAG_FRAUD\n\n    # A disputed charge always opens a case, regardless of other signals\n    @Rule(OrderEvent.status.eq(\"disputed\"))\n    def dispute(self, event: OrderEvent) -> Action:\n        return Action.OPEN_DISPUTE\n\n    # Failed payment with retries remaining → schedule another attempt\n    @Rule(\n        OrderEvent.status.eq(\"payment_failed\")\n        & OrderEvent.retry_count.lt(3)\n    )\n    def retry(self, event: OrderEvent) -> Action:\n        return Action.SCHEDULE_RETRY\n\n    # VIP customer exhausted retries → human touch, not automation\n    @Rule(\n        OrderEvent.status.eq(\"payment_failed\")\n        & OrderEvent.customer_tier.eq(\"vip\")\n        & OrderEvent.retry_count.ge(3)\n    )\n    def vip_payment_exhausted(self, event: OrderEvent) -> Action:\n        return Action.ALERT_ACCOUNT_MANAGER\n\n    # Fires only when nothing above matched\n    @Default\n    def fallback(self, event: OrderEvent) -> Action:\n        return Action.STANDARD_PROCESSING\n```\n\nRules evaluate **top-to-bottom**. The first match wins — no fall-through, no ambiguity. `@Default`\n\nis always last, regardless of where you declare it.\n\n```\nrouter = OrderRouter()\n\nrouter.run(OrderEvent(\n    status=\"payment_failed\",\n    amount_usd=149.00,\n    customer_tier=\"vip\",\n    retry_count=3,\n    risk_score=0.2,\n))\n# → Action.ALERT_ACCOUNT_MANAGER  (matched `vip_payment_exhausted`, stopped there)\n```\n\nThe engine is `Generic[OrderEvent, Action]`\n\n— ** run() returns Action | None**, fully typed. No casting, no\n\n`Any`\n\n, no surprises.\n\nTrade-off to know:`airules`\n\nuses 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`\n\nmust come before`dispute`\n\nbecause a high-risk disputed order should be flagged for fraud, not just opened as a dispute case.\n\nIf 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.\n\n`airules`\n\npredicates are first-class objects that **serialize to plain dicts**:\n\n```\np = (\n    OrderEvent.status.eq(\"payment_failed\")\n    & OrderEvent.retry_count.lt(3)\n)\n\np.to_dict()\n# {\n#   \"op\": \"and\",\n#   \"operands\": [\n#     {\"op\": \"eq\",  \"field\": \"status\",      \"value\": \"payment_failed\"},\n#     {\"op\": \"lt\",  \"field\": \"retry_count\", \"value\": 3}\n#   ]\n# }\n\n# Round-trips cleanly\nrestored = Predicate.from_dict(p.to_dict())\n```\n\nOne level up, `describe()`\n\ndumps the **entire rule set** — every rule, its predicate, its priority:\n\n``` python\nimport json\nprint(json.dumps(OrderRouter.describe(), indent=2))\n# {\n#   \"facts\": [{\"name\": \"OrderEvent\", \"fields\": {\n#     \"status\": \"Field\", \"amount_usd\": \"NumberField\",\n#     \"customer_tier\": \"Field\", \"retry_count\": \"NumberField\", \"risk_score\": \"NumberField\"\n#   }}],\n#   \"rules\": [\n#     {\"name\": \"high_risk\",            \"predicate\": {...}, \"priority\": 5, \"is_default\": false},\n#     {\"name\": \"dispute\",              \"predicate\": {...}, \"priority\": 4, \"is_default\": false},\n#     {\"name\": \"retry\",                \"predicate\": {...}, \"priority\": 3, \"is_default\": false},\n#     {\"name\": \"vip_payment_exhausted\",\"predicate\": {...}, \"priority\": 2, \"is_default\": false},\n#     {\"name\": \"fallback\",             \"predicate\": null,  \"priority\": 0, \"is_default\": true}\n#   ]\n# }\n```\n\nStore 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**.\n\n```\nIncoming order event\n        │\n        ▼\n┌──────────────────┐    match     ┌──────────────────┐\n│   OrderRouter    │ ──────────▶  │  Return Action   │  ← ~0ms, $0.00\n│   (airules)      │              └──────────────────┘\n│                  │    no match\n│                  │ ──────────▶  ┌──────────────────┐\n└──────────────────┘              │  LLM fallback    │  ← ~800ms, costs tokens\n                                  └──────────────────┘\n```\n\nRules handle the known cases for free. The LLM handles only what genuinely falls through — unusual combinations of signals that no single rule anticipated.\n\n`@Default`\n\nfallback\nThe LLM call lives inside `@Default`\n\n— 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`\n\nmethod:\n\n``` python\nimport json\nfrom pydantic_ai import Agent\nfrom airules import Fact, Field, NumberField, KnowledgeEngine, Rule, Default\n\nclass OrderRouter(KnowledgeEngine[OrderEvent, Action]):\n\n    @Rule(OrderEvent.risk_score.ge(0.85))\n    def high_risk(self, event: OrderEvent) -> Action:\n        return Action.FLAG_FRAUD\n\n    @Rule(OrderEvent.status.eq(\"disputed\"))\n    def dispute(self, event: OrderEvent) -> Action:\n        return Action.OPEN_DISPUTE\n\n    @Rule(\n        OrderEvent.status.eq(\"payment_failed\")\n        & OrderEvent.retry_count.lt(3)\n    )\n    def retry(self, event: OrderEvent) -> Action:\n        return Action.SCHEDULE_RETRY\n\n    @Rule(\n        OrderEvent.status.eq(\"payment_failed\")\n        & OrderEvent.customer_tier.eq(\"vip\")\n        & OrderEvent.retry_count.ge(3)\n    )\n    def vip_payment_exhausted(self, event: OrderEvent) -> Action:\n        return Action.ALERT_ACCOUNT_MANAGER\n\n    @Default\n    async def llm_triage(self, event: OrderEvent) -> Action:\n        # Only reached for combinations no rule anticipated —\n        # e.g. a wholesale customer with moderate risk (0.6) and a fulfilled\n        # order that was just disputed for the first time.\n        rules_schema = json.dumps(type(self).describe(), indent=2)\n\n        agent = Agent(\n            \"openai-chat:gpt-5.4-mini\",,\n            output_type=Action,\n            system_prompt=(\n                \"You are an order event classifier. \"\n                \"The rules below already handle known cases deterministically — \"\n                \"you only receive events that matched none of them. \"\n                \"Decide the best action for this edge case.\\n\\n\"\n                f\"Existing rules:\\n{rules_schema}\"\n            ),\n        )\n        result = await agent.run(str(event))\n        return result.output\n\nrouter = OrderRouter()\naction = await router.run_async(event)\n```\n\n`type(self).describe()`\n\nis the key line\nWithout it, your LLM and your rules engine are two separate systems with no shared understanding. The model might return `SCHEDULE_RETRY`\n\nfor an event that should have been caught by the `high_risk`\n\nrule — creating inconsistencies that are maddening to debug.\n\n**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.\n\nThe model isn't duplicating your rules. It's extending them.\n\n`run_async`\n\nand`evaluate_async`\n\nhandle async`@Default`\n\nmethods automatically. Your synchronous`@Rule`\n\nmethods don't need to change.\n\nOnce this is running, the number to watch isn't \"how many events did the engine handle.\" It's **\"what percentage hit @Default?\"**\n\nThe gap between those numbers is the payoff from getting your rules right.\n\nThe engine accepts an observer that receives every evaluation result — without blocking the engine:\n\n``` python\nfrom airules import LoggingObserver\n\n# Built-in: logs every evaluation, warns loudly on @Default hits\nrouter = OrderRouter(observer=LoggingObserver())\nawait router.run_async(event)\n# INFO     OrderRouter: rule='retry' result=<Action.SCHEDULE_RETRY: 'schedule_retry'>\n# WARNING  OrderRouter: default fired — status='fulfilled', risk_score=0.62, customer_tier='wholesale'\n```\n\nFor custom telemetry — Prometheus counters, Datadog traces, a Slack alert when the default rate spikes — implement `OutcomeObserver`\n\n:\n\n``` python\nimport logging\nfrom airules import OutcomeObserver, Outcome\n\nclass DefaultRateObserver(OutcomeObserver[OrderEvent, Action]):\n    def __init__(self) -> None:\n        self.total = 0\n        self.defaults = 0\n\n    def observe(\n        self,\n        outcome: Outcome[OrderEvent, Action],\n        engine: KnowledgeEngine[OrderEvent, Action],\n    ) -> None:\n        self.total += 1\n        if outcome.is_default:\n            self.defaults += 1\n            logging.warning(\"no rule matched: %s\", outcome.fact)\n\n    @property\n    def default_rate(self) -> float:\n        return self.defaults / self.total if self.total else 0.0\n```\n\nYour `@Default`\n\nhits aren't failures — they're a data-driven roadmap:\n\n`@Default`\n\nhit with the full `outcome.fact`\n\n`@Rule`\n\nfor each pattern you can nameThe LLM is effectively labeling the gaps in your rule set. Every correct `@Default`\n\nclassification is a signal that says \"this combination needs a rule.\" Over time, the engine handles more and more traffic — the LLM handles less and less, reserved for inputs that genuinely benefit from its reasoning.\n\n**Good fit:**\n\n**Poor fit:**\n\n`if`\n\nstatement is the right tool. No ceremony needed.\n\nProduction note:`airules`\n\nis experimental at v0.1.1. The core API is stable in spirit, but details may still change. Pin your version and read the changelog before upgrading in production.\n\nOptimizing LLM usage isn't just about picking a cheaper model or adding a cache. It's about being honest about which parts of your pipeline actually need intelligence — and handling everything else deterministically.\n\nA typed rules engine that encodes what you already know is the correct complement to an LLM. `airules`\n\nmakes that layer explicit, auditable, and — via `describe()`\n\n— self-documenting enough to feed back into the LLM itself as context.\n\n**Get started:**\n\n```\npip install ai-rules-engine\n```\n\n[Full working examples](https://github.com/tbilaszewski/ai-rules/tree/main/examples/) | [Documentation](https://ai-rules-eight.vercel.app/)\n\nBuilding a similar hybrid system? Share your default rate in the comments — curious how different domains compare. And if `airules`\n\nis missing something you need, open an issue. The roadmap is driven by real use cases.", "url": "https://wpnews.pro/news/your-llm-shouldn-t-be-handling-if-else-logic-here-s-a-smarter-way", "canonical_source": "https://dev.to/tbilaszewski/your-llm-shouldnt-be-handling-ifelse-logic-heres-a-smarter-way-5d4p", "published_at": "2026-06-18 13:06:14+00:00", "updated_at": "2026-06-18 13:21:37.851486+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-products"], "entities": ["airules", "OpenAI", "GPT-4o-mini", "Pydantic"], "alternates": {"html": "https://wpnews.pro/news/your-llm-shouldn-t-be-handling-if-else-logic-here-s-a-smarter-way", "markdown": "https://wpnews.pro/news/your-llm-shouldn-t-be-handling-if-else-logic-here-s-a-smarter-way.md", "text": "https://wpnews.pro/news/your-llm-shouldn-t-be-handling-if-else-logic-here-s-a-smarter-way.txt", "jsonld": "https://wpnews.pro/news/your-llm-shouldn-t-be-handling-if-else-logic-here-s-a-smarter-way.jsonld"}}