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.

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.
Many products allow users to filter collections of data.
Examples include:
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:
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:
The rest of this article walks through the core ideas behind that implementation.
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.
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
170end1def 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 endIn this setup, the LLM never interacts directly with the query layer.
It produces a validated CustomerFilter which is used by the application.
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:
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.
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:
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.
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.