Designing AI Features That Actually Help Users

elixir

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:

    %{
      min_total_spend: 50000,
      last_order_before_days: 90,
      status: :inactive
    }

    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.

    defmodule ExampleLiveviewAiUx.Customers.CustomerFilter do
      use Ecto.Schema
      use Instructor
      import Ecto.Changeset
    
      @llm_doc """
      Extract customer filter parameters from natural language. Only populate fields
      that are explicitly mentioned — leave everything else as null.
    
      Field reference:
      - min_total_spend: use when the request says "spent more than / at least X". Convert dollars to cents (× 100).
        e.g. "more than $500" → min_total_spend: 50000
      - max_total_spend: use when the request says "spent less than / at most X". Convert dollars to cents (× 100).
      - min_orders_count: use when the request says "ordered more than / at least N times"
      - max_orders_count: use when the request says "ordered fewer than / at most N times"
      - 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
      - last_order_after_days: use when the request says "ordered recently / within the last X days"
      - signed_up_before_days: use when the request says "signed up more than X days ago / a long time ago"
      - signed_up_after_days: use when the request says "signed up recently / within the last X days"
      - status: only set if explicitly mentioned. One of: "active", "inactive", "churn_risk", "vip"
      - country: only set if a country is explicitly mentioned
      - segment: only set if explicitly mentioned. One of: "smb", "enterprise", "consumer"
      - has_open_support_ticket: only set if explicitly mentioned. true or false
    
      Time references: "recently" = 30 days, "a while / long time" = 90 days, "X months" = X × 30 days.
      """
    
      @primary_key false
      embedded_schema do
        field :min_total_spend, :integer
        field :max_total_spend, :integer
        field :min_orders_count, :integer
        field :max_orders_count, :integer
        field :last_order_before_days, :integer
        field :last_order_after_days, :integer
        field :signed_up_before_days, :integer
        field :signed_up_after_days, :integer
        field :status, Ecto.Enum, values: [:active, :inactive, :churn_risk, :vip]
        field :country, :string
        field :segment, Ecto.Enum, values: [:smb, :enterprise, :consumer]
        field :has_open_support_ticket, :boolean
      end
    
      @fields [
        :min_total_spend,
        :max_total_spend,
        :min_orders_count,
        :max_orders_count,
        :last_order_before_days,
        :last_order_after_days,
        :signed_up_before_days,
        :signed_up_after_days,
        :status,
        :country,
        :segment,
        :has_open_support_ticket
      ]
    
      def new do
        %__MODULE__{}
      end
    
      def changeset(filter, attrs) do
        filter
        |> cast(attrs, @fields)
        |> validate_number(:min_total_spend, greater_than_or_equal_to: 0)
        |> validate_number(:max_total_spend, greater_than_or_equal_to: 0)
        |> validate_number(:min_orders_count, greater_than_or_equal_to: 0)
        |> validate_number(:max_orders_count, greater_than_or_equal_to: 0)
        |> validate_number(:last_order_before_days, greater_than: 0)
        |> validate_number(:last_order_after_days, greater_than: 0)
        |> validate_number(:signed_up_before_days, greater_than: 0)
        |> validate_number(:signed_up_after_days, greater_than: 0)
        |> validate_spend_range()
        |> validate_orders_range()
      end
    
      def metadata do
        %{
          min_total_spend: %{
            label: "Minimum Total Spend",
            type: :number,
            description: "Minimum amount spent (in cents)"
          },
          max_total_spend: %{
            label: "Maximum Total Spend",
            type: :number,
            description: "Maximum amount spent (in cents)"
          },
          min_orders_count: %{
            label: "Minimum Orders Count",
            type: :number,
            description: "Minimum number of orders placed"
          },
          max_orders_count: %{
            label: "Maximum Orders Count",
            type: :number,
            description: "Maximum number of orders placed"
          },
          last_order_before_days: %{
            label: "Last Order Before (Days)",
            type: :number,
            description: "Last order was before X days ago"
          },
          last_order_after_days: %{
            label: "Last Order After (Days)",
            type: :number,
            description: "Last order was after X days ago"
          },
          signed_up_before_days: %{
            label: "Signed Up Before (Days)",
            type: :number,
            description: "Signed up before X days ago"
          },
          signed_up_after_days: %{
            label: "Signed Up After (Days)",
            type: :number,
            description: "Signed up after X days ago"
          },
          status: %{
            label: "Status",
            type: :select,
            options: [:active, :inactive, :churn_risk, :vip],
            description: "Customer status"
          },
          country: %{
            label: "Country",
            type: :text,
            description: "Customer country"
          },
          segment: %{
            label: "Segment",
            type: :select,
            options: [:smb, :enterprise, :consumer],
            description: "Customer segment"
          },
          has_open_support_ticket: %{
            label: "Has Open Support Ticket",
            type: :checkbox,
            description: "Customer has an open support ticket"
          }
        }
      end
    
      defp validate_spend_range(changeset) do
        min_spend = get_field(changeset, :min_total_spend)
        max_spend = get_field(changeset, :max_total_spend)
    
        case {min_spend, max_spend} do
          {min, max} when is_integer(min) and is_integer(max) and min > max ->
            add_error(changeset, :max_total_spend, "must be greater than minimum spend")
    
          _ ->
            changeset
        end
      end
    
      defp validate_orders_range(changeset) do
        min_orders = get_field(changeset, :min_orders_count)
        max_orders = get_field(changeset, :max_orders_count)
    
        case {min_orders, max_orders} do
          {min, max} when is_integer(min) and is_integer(max) and min > max ->
            add_error(changeset, :max_orders_count, "must be greater than minimum orders")
    
          _ ->
            changeset
        end
      end
    end
    def parse_filter_from_text(text) when is_binary(text) and text != "" do
        case Instructor.chat_completion(
               model: "gpt-4o-mini",
               max_retries: 3,
               response_model: CustomerFilter,
               messages: [
                 %{
                   role: "system",
                   content: """
                   You extract customer filter parameters from natural language queries. Follow these rules exactly:
    
                   CRITICAL RULES:
                   1. Only set fields that are EXPLICITLY mentioned in the request. Leave everything else null.
                   2. Do NOT assume or infer values that are not stated. For example, do not set country unless the user mentions a country.
                   3. Do NOT set has_open_support_ticket unless the user explicitly mentions support tickets.
    
                   SPEND DIRECTION (very important — get this right):
                   - "spent MORE than X" / "spent at least X" / "high spenders" → min_total_spend (NOT max_total_spend)
                   - "spent LESS than X" / "spent at most X" / "low spenders" → max_total_spend (NOT min_total_spend)
                   - Always convert dollar amounts to cents by multiplying by 100.
                   """
                 },
                 %{role: "user", content: text}
               ]
             ) do
          {:ok, filter} ->
            # Strip nil values so only fields the LLM actually populated are cast.
            # This also prevents string "null" values from leaking through as
            # literal filter values.
            sanitized =
              filter |> Map.from_struct() |> Map.reject(fn {_, v} -> is_nil(v) or v == "null" end)
    
            case CustomerFilter.changeset(%CustomerFilter{}, sanitized) do
              %{valid?: true} = changeset -> {:ok, Ecto.Changeset.apply_changes(changeset)}
              changeset -> {:error, "Invalid filter parameters: #{inspect(changeset.errors)}"}
            end
    
          {:error, reason} ->
            {:error, "Failed to process filter intent: #{inspect(reason)}"}
        end
      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.

    paper airplane

    Thoughts? Like what you just read?

    Let's keep the conversation going. Share it on social or reach out and tell us your take.

    mimiquate petmimiquate pet