elixir

Designing AI Features That Actually Help Users

Many AI features fail not because of the model, but because of the interface. Instead of replacing workflows with chatbots, we can use AI to translate user intent into structured filters while keeping the system deterministic. This article explores a practical pattern for doing that in Phoenix applications.

Juan Azambuja avatar

Juan Azambuja

5 minutes - March 24, 2026

Part 1: Letting Users Describe Complex Filters


AI is being added to products at a rapid pace. Some features feel genuinely useful. Others feel unpredictable or unnecessary.
In many cases the difference is not the model. It is the interface.
A common pattern today is replacing an existing workflow with a chat box and expecting users to describe everything in natural language. That often removes the structure that made the workflow reliable in the first place.
In practice, the most useful AI features I’ve seen do the opposite. They keep the structure of the system and use AI to translate user intent into something the system already understands.
One place where this works particularly well is filtering.

The Problem With Complex Filters

Many products allow users to filter collections of data.
Examples include:

  • customers in a CRM
  • orders in an ecommerce system
  • logs in an observability tool
  • tasks in a project management app
  • At first the UI is simple. A few dropdowns or checkboxes are enough.
    But as the product grows, filtering often becomes more complex. You start seeing things like:

  • nested conditions
  • large numbers of filter options
  • combinations of AND/OR logic
  • A UI that once felt simple turns into a small query builder.
    Some platforms go even further and introduce their own query languages so users can express complex filters more flexibly.


    For example:

    status:inactive AND total_spend > 500 AND last_order < 90d

    These approaches are powerful, but they also introduce friction. Users now have to learn a syntax before they can ask what is often a simple question.
    In many cases users already know what they want. The difficulty is translating that intent into the structure required by the interface.


    For example:

    "

    customers who spent more than $500 in the last 3 months and haven't placed an order recently

    Representing this through dropdowns, query builders, or a custom filter language can take several steps.

    To make this more concrete, I built a small Phoenix LiveView demo that implements this pattern: https://github.com/juanazam/example_liveview_ai_u/


    The app includes:

  • a traditional filtering UI
  • an AI-powered intent input
  • a shared filtering layer used by both
  • The rest of this article walks through the core ideas behind that implementation.

    Adding an Intent Layer

    Instead of forcing the user to construct the filter manually, we can let them describe what they want. The idea is simple, use AI to translate natural language into a structured filter that the application already supports. The system still controls the query. The AI only helps interpret the user’s intent.

    A user might type:

    "

    customers who spent more than $500 in the last 3 months and haven't ordered recently

    The AI converts that into structured data such as:

    1%{
    2  min_total_spend: 50000,
    3  last_order_before_days: 90,
    4  status: :inactive
    5}

    From there the application builds the query using its existing filtering logic.
    The important detail is that the model is not generating SQL or database queries. It is only producing structured parameters that the system already understands.

    Implementing This Pattern in Elixir

    Elixir is a good fit for this pattern because it makes it easy to define clear boundaries between data, validation, and execution.
    In the demo app, I use a dedicated response model for the LLM and then map that result into the application’s actual filter struct.
    This separation is intentional, the LLM response model defines what the model is allowed to return.
    The application filter remains the source of truth for querying.

    1defmodule ExampleLiveviewAiUx.Customers.CustomerFilter do
    2  use Ecto.Schema
    3  use Instructor
    4  import Ecto.Changeset
    5
    6  @llm_doc """
    7  Extract customer filter parameters from natural language. Only populate fields
    8  that are explicitly mentioned — leave everything else as null.
    9
    10  Field reference:
    11  - min_total_spend: use when the request says "spent more than / at least X". Convert dollars to cents (× 100).
    12    e.g. "more than $500" → min_total_spend: 50000
    13  - max_total_spend: use when the request says "spent less than / at most X". Convert dollars to cents (× 100).
    14  - min_orders_count: use when the request says "ordered more than / at least N times"
    15  - max_orders_count: use when the request says "ordered fewer than / at most N times"
    16  - last_order_before_days: use when the request says "haven't ordered in X days/months" or "last order was a long time ago". e.g. "no order in 6 months" → last_order_before_days: 180
    17  - last_order_after_days: use when the request says "ordered recently / within the last X days"
    18  - signed_up_before_days: use when the request says "signed up more than X days ago / a long time ago"
    19  - signed_up_after_days: use when the request says "signed up recently / within the last X days"
    20  - status: only set if explicitly mentioned. One of: "active", "inactive", "churn_risk", "vip"
    21  - country: only set if a country is explicitly mentioned
    22  - segment: only set if explicitly mentioned. One of: "smb", "enterprise", "consumer"
    23  - has_open_support_ticket: only set if explicitly mentioned. true or false
    24
    25  Time references: "recently" = 30 days, "a while / long time" = 90 days, "X months" = X × 30 days.
    26  """
    27
    28  @primary_key false
    29  embedded_schema do
    30    field :min_total_spend, :integer
    31    field :max_total_spend, :integer
    32    field :min_orders_count, :integer
    33    field :max_orders_count, :integer
    34    field :last_order_before_days, :integer
    35    field :last_order_after_days, :integer
    36    field :signed_up_before_days, :integer
    37    field :signed_up_after_days, :integer
    38    field :status, Ecto.Enum, values: [:active, :inactive, :churn_risk, :vip]
    39    field :country, :string
    40    field :segment, Ecto.Enum, values: [:smb, :enterprise, :consumer]
    41    field :has_open_support_ticket, :boolean
    42  end
    43
    44  @fields [
    45    :min_total_spend,
    46    :max_total_spend,
    47    :min_orders_count,
    48    :max_orders_count,
    49    :last_order_before_days,
    50    :last_order_after_days,
    51    :signed_up_before_days,
    52    :signed_up_after_days,
    53    :status,
    54    :country,
    55    :segment,
    56    :has_open_support_ticket
    57  ]
    58
    59  def new do
    60    %__MODULE__{}
    61  end
    62
    63  def changeset(filter, attrs) do
    64    filter
    65    |> cast(attrs, @fields)
    66    |> validate_number(:min_total_spend, greater_than_or_equal_to: 0)
    67    |> validate_number(:max_total_spend, greater_than_or_equal_to: 0)
    68    |> validate_number(:min_orders_count, greater_than_or_equal_to: 0)
    69    |> validate_number(:max_orders_count, greater_than_or_equal_to: 0)
    70    |> validate_number(:last_order_before_days, greater_than: 0)
    71    |> validate_number(:last_order_after_days, greater_than: 0)
    72    |> validate_number(:signed_up_before_days, greater_than: 0)
    73    |> validate_number(:signed_up_after_days, greater_than: 0)
    74    |> validate_spend_range()
    75    |> validate_orders_range()
    76  end
    77
    78  def metadata do
    79    %{
    80      min_total_spend: %{
    81        label: "Minimum Total Spend",
    82        type: :number,
    83        description: "Minimum amount spent (in cents)"
    84      },
    85      max_total_spend: %{
    86        label: "Maximum Total Spend",
    87        type: :number,
    88        description: "Maximum amount spent (in cents)"
    89      },
    90      min_orders_count: %{
    91        label: "Minimum Orders Count",
    92        type: :number,
    93        description: "Minimum number of orders placed"
    94      },
    95      max_orders_count: %{
    96        label: "Maximum Orders Count",
    97        type: :number,
    98        description: "Maximum number of orders placed"
    99      },
    100      last_order_before_days: %{
    101        label: "Last Order Before (Days)",
    102        type: :number,
    103        description: "Last order was before X days ago"
    104      },
    105      last_order_after_days: %{
    106        label: "Last Order After (Days)",
    107        type: :number,
    108        description: "Last order was after X days ago"
    109      },
    110      signed_up_before_days: %{
    111        label: "Signed Up Before (Days)",
    112        type: :number,
    113        description: "Signed up before X days ago"
    114      },
    115      signed_up_after_days: %{
    116        label: "Signed Up After (Days)",
    117        type: :number,
    118        description: "Signed up after X days ago"
    119      },
    120      status: %{
    121        label: "Status",
    122        type: :select,
    123        options: [:active, :inactive, :churn_risk, :vip],
    124        description: "Customer status"
    125      },
    126      country: %{
    127        label: "Country",
    128        type: :text,
    129        description: "Customer country"
    130      },
    131      segment: %{
    132        label: "Segment",
    133        type: :select,
    134        options: [:smb, :enterprise, :consumer],
    135        description: "Customer segment"
    136      },
    137      has_open_support_ticket: %{
    138        label: "Has Open Support Ticket",
    139        type: :checkbox,
    140        description: "Customer has an open support ticket"
    141      }
    142    }
    143  end
    144
    145  defp validate_spend_range(changeset) do
    146    min_spend = get_field(changeset, :min_total_spend)
    147    max_spend = get_field(changeset, :max_total_spend)
    148
    149    case {min_spend, max_spend} do
    150      {min, max} when is_integer(min) and is_integer(max) and min > max ->
    151        add_error(changeset, :max_total_spend, "must be greater than minimum spend")
    152
    153      _ ->
    154        changeset
    155    end
    156  end
    157
    158  defp validate_orders_range(changeset) do
    159    min_orders = get_field(changeset, :min_orders_count)
    160    max_orders = get_field(changeset, :max_orders_count)
    161
    162    case {min_orders, max_orders} do
    163      {min, max} when is_integer(min) and is_integer(max) and min > max ->
    164        add_error(changeset, :max_orders_count, "must be greater than minimum orders")
    165
    166      _ ->
    167        changeset
    168    end
    169  end
    170end
    1def parse_filter_from_text(text) when is_binary(text) and text != "" do
    2    case Instructor.chat_completion(
    3           model: "gpt-4o-mini",
    4           max_retries: 3,
    5           response_model: CustomerFilter,
    6           messages: [
    7             %{
    8               role: "system",
    9               content: """
    10               You extract customer filter parameters from natural language queries. Follow these rules exactly:
    11
    12               CRITICAL RULES:
    13               1. Only set fields that are EXPLICITLY mentioned in the request. Leave everything else null.
    14               2. Do NOT assume or infer values that are not stated. For example, do not set country unless the user mentions a country.
    15               3. Do NOT set has_open_support_ticket unless the user explicitly mentions support tickets.
    16
    17               SPEND DIRECTION (very important — get this right):
    18               - "spent MORE than X" / "spent at least X" / "high spenders" → min_total_spend (NOT max_total_spend)
    19               - "spent LESS than X" / "spent at most X" / "low spenders" → max_total_spend (NOT min_total_spend)
    20               - Always convert dollar amounts to cents by multiplying by 100.
    21               """
    22             },
    23             %{role: "user", content: text}
    24           ]
    25         ) do
    26      {:ok, filter} ->
    27        # Strip nil values so only fields the LLM actually populated are cast.
    28        # This also prevents string "null" values from leaking through as
    29        # literal filter values.
    30        sanitized =
    31          filter |> Map.from_struct() |> Map.reject(fn {_, v} -> is_nil(v) or v == "null" end)
    32
    33        case CustomerFilter.changeset(%CustomerFilter{}, sanitized) do
    34          %{valid?: true} = changeset -> {:ok, Ecto.Changeset.apply_changes(changeset)}
    35          changeset -> {:error, "Invalid filter parameters: #{inspect(changeset.errors)}"}
    36        end
    37
    38      {:error, reason} ->
    39        {:error, "Failed to process filter intent: #{inspect(reason)}"}
    40    end
    41  end

    In this setup, the LLM never interacts directly with the query layer.
    It produces a validated CustomerFilter which is used by the application.

    What Happens When the Model Gets It Wrong?

    Large language models are probabilistic systems. Even with a clear prompt and a defined response model, they sometimes return data that does not fit the expected structure.
    That is where Instructor helps.
    It allows you to define a structured response model and retry when the model fails to produce valid output.


    Without this, you would need to handle:

  • parsing
  • validation
  • repair prompts
  • retries
  • A Valid Schema Does Not Guarantee a Correct Interpretation

    Even when the response fits the schema perfectly, it may still not reflect what the user actually meant.


    For example:

    "

    customers that spend a lot but haven't bought anything in a while

    The model might produce:

    %CustomerFilter{ min_total_spend: 50000, last_order_before_days: 90 }

    This is a valid result. It matches the schema and can be used to build a query. But it may still be wrong.
    Maybe in that business context “spend a lot” should mean 2000. Maybe “a while” means six months instead of three.
    Schema validation solves the structural problem. It does not solve the semantic one.
    There are a few ways to improve this.
    One option is to include a confidence field in the response and let the model indicate how certain it is about the interpretation. This can be useful as a UI signal, but it should be treated as a heuristic rather than a guarantee.
    Another option is to return multiple possible interpretations instead of forcing a single answer. This makes ambiguity explicit and allows the user to choose the option that best matches their intent.
    In practice, both approaches can help, but neither replaces the need for a UI where users can review and adjust the generated filters.

    Next Steps

    As part of building the demo, I also experimented with a prototype of a reusable filtering layer for Ecto schemas.

    https://github.com/juanazam/example_liveview_ai_u/tree/introduce_filter_dsl

    The idea is to define filters once and use that definition to:

  • generate composable query logic
  • generate metadata for building filter UIs
  • support AI-assisted parsing into the same structure
  • In that model, the filter definition becomes the source of truth, and both the UI and the AI layer become different interfaces over it.


    This is still an early prototype, but it feels like it could be worth exploring.

    Closing

    AI works best when it removes friction without removing structure.
    Letting users describe what they want while the application remains responsible for how it is executed is a simple but powerful pattern.
    Filtering is one example, but the same idea applies anywhere users struggle to express intent through rigid interfaces.

    mimiquate petmimiquate pet