Imp.react/3 is itself a composition: a predictor that chooses the next step,
a loop around it, and a few tools. We can build our own the same way. A
program is any struct whose module implements the Imp.Module behaviour, and
its stages are ordinary Imp programs held in its fields.
Let's split routing into two stages: first say plainly what is going wrong, then route on the ticket and that summary.
defmodule TicketTriage do
@behaviour Imp.Module
defstruct [:analyze, :route]
def new(lm) do
%__MODULE__{
analyze:
"ticket -> problem: string"
|> Imp.signature("Say in one sentence what is going wrong, in plain words.")
|> Imp.predict(lm: lm),
route:
"ticket, problem -> team: enum[atlas,harbor,beacon,quill]"
|> Imp.signature("Route the support ticket to the squad that owns it.")
|> Imp.predict(lm: lm, adapter: Imp.Adapter.JSON)
}
end
@impl true
def call(%__MODULE__{} = triage, %{ticket: ticket}) do
with {:ok, analysis} <- Imp.call(triage.analyze, %{ticket: ticket}),
problem = Imp.get(analysis, :problem),
{:ok, routing} <- Imp.call(triage.route, %{ticket: ticket, problem: problem}) do
{:ok, Imp.prediction(problem: problem, team: Imp.get(routing, :team))}
end
end
@impl true
def optimizer_predictors(triage), do: [analyze: triage.analyze, route: triage.route]
@impl true
def update_optimizer_predictor(triage, :analyze, fun), do: %{triage | analyze: fun.(triage.analyze)}
def update_optimizer_predictor(triage, :route, fun), do: %{triage | route: fun.(triage.route)}
endnew/1 builds the stages. call/2 runs them: plain Elixir, with with to
stop at the first error. A stage that fails returns its error, and the caller
gets it unchanged. Imp.prediction/1 builds a prediction from fields, so
callers read problem and team with Imp.get/2, as from any other program.
triage = TicketTriage.new(lm)
{:ok, prediction} =
Imp.call(triage, %{ticket: "Receipt emails have not been sent for any order since the deploy."})
{Imp.get(prediction, :problem), Imp.get(prediction, :team)}
#=> {"Since the deploy, receipt emails are not being sent for any orders.", "beacon"}The summary is accurate and the route is wrong: by our charters, email that isn't being delivered is a platform failure, harbor's.
The last two functions are optional. They name the predictors inside the
program, so optimizers can improve each stage the way they improve a single
predictor. Without them, TicketTriage still runs, evaluates, and serves; it
just can't be tuned.
Why split a program
This split won't make our router more accurate by itself; the model still doesn't know our squads. Splitting pays when a task outgrows one request:
- Different models for different stages. Each stage has its own
lm:, so a cheap model can summarize and a stronger one can decide. - Checks between stages.
call/2is ordinary code, so it can validate, branch, retry, or call the next stage several times at once withTask. - Stages we can measure. Each stage is a program, so we can call, test, and score one on its own.
- Reuse. A good summarizer isn't specific to routing.
In tests, TicketTriage.new(scripted) gives both stages a scripted model.
Modules and composition has
more patterns: branching, retries, and parallel stages.
Next: Measuring →