Recursive Workflows

September 17, 2026 ยท View on GitHub

Recursion with Jev is a handle_answer/3 clause that replies again. Every recursive workflow has the same three parts: context in the tag, a base case as a clause, and a bound in a guard.

Tree descent

A choice is limited to 255 options, and the hierarchical classification cookbook handles bigger spaces by descending one level per call. Over a DOM that is one question per level among the children, stopping when confidence drops or depth runs out:

defmodule Locate do
  use Jev.Server

  @max_depth 8

  def init(_), do: {:ok, %{}}

  def handle_cast({:locate, page, reply_to}, s), do: descend(page, page.root, 0, reply_to, s)

  def handle_answer(%{which: pick, confidence: %{which: c}}, {page, node, depth, reply_to}, s)
      when c > 0.6 and depth < @max_depth do
    child = Enum.at(children(node), pick |> Atom.to_string() |> String.to_integer())
    descend(page, child, depth + 1, reply_to, s)
  end

  def handle_answer(_reply, {_page, node, _depth, reply_to}, s), do: found(node, reply_to, s)

  defp descend(page, node, depth, reply_to, s) do
    case children(node) do
      kids when length(kids) < 2 ->
        found(node, reply_to, s)

      kids ->
        options = kids |> Enum.with_index() |> Map.new(fn {kid, i} -> {:"#{i}", summary(kid)} end)
        state = %{title: page.title, parent: summary(node)}
        {:reply, {{page, node, depth, reply_to}, state, which: {"Which child holds the article body?", options}}, s}
    end
  end

  defp found(node, reply_to, s) do
    send(reply_to, {:located, node})
    {:noreply, s}
  end
end

Each level costs one request. Stopping when confidence drops is the natural base case, and it is also where you learn whether confidence at deep levels is trustworthy for your data.

Bisection

"Where does this document talk about refunds?" costs log n calls instead of scoring every paragraph. Ask about both halves at once; when both come back likely, follow both, and the recursion becomes a tree search that returns several spots:

def handle_answer({{:bisect, chunk}, %{left: l, right: r}}, s) when l > 0.5 and r > 0.5,
  do: {:noreply, s |> bisect(left(chunk)) |> bisect(right(chunk))}

def handle_answer({{:bisect, chunk}, %{left: l}}, s) when l > 0.5, do: bisect(s, left(chunk))
def handle_answer({{:bisect, chunk}, %{right: r}}, s) when r > 0.5, do: bisect(s, right(chunk))
def handle_answer({{:bisect, _chunk}, _reply}, s), do: {:noreply, s}

defp bisect(s, chunk) when byte_size(chunk) < 800, do: hit(s, chunk)

defp bisect(s, chunk) do
  {:reply, {{:bisect, chunk}, chunk,
     left: "Does the first half mention refunds?",
     right: "Does the second half mention refunds?"}, s}
end

Verify and repair

Extract each field as a choice over parsed candidates, verify every field with a yes/no question framed so that true means "something is wrong", and re-ask any flagged field with the rejected candidate removed. The candidate lists shrink, so it terminates by construction. This is the extraction cascade with the expensive model replaced by another round of Jev:

def handle_answer({{:verify, doc, picks, candidates}, reply}, s) do
  case for {field, p} <- reply, is_float(p) and p > 0.7, do: field do
    [] ->
      {:noreply, done(s, picks)}

    bad ->
      remaining = Map.new(candidates, fn {f, cs} -> {f, if(f in bad, do: cs -- [picks[f]], else: cs)} end)
      extract(s, doc, Map.drop(picks, bad), remaining)
  end
end

Cascade

Ask a broad question; if unsure, ask a narrower one, possibly of a different model. The tag records the stage:

def handle_answer(%{kind: k, confidence: %{kind: c}}, {:broad, text}, s) when c < 0.5 do
  {:reply, {{:narrow, text}, text, [kind: {"Which fits better?", top_two(k)}], [model: "jev-preview"]}, s}
end

def handle_answer(%{kind: k}, {_stage, text}, s), do: classified(text, k, s)

Crawling

The recursion can cross processes. A page server asks a score question per outgoing link for crawl priority and, for each link above the bar, starts a child page server under a DynamicSupervisor. A Registry keyed by URL dedupes, the depth lives in the child's init argument, and the whole crawl is a supervision tree you can watch in Observer.

Clarifying conversation

Intent routing where a middling confidence asks the user a clarifying question instead of acting. Their reply is appended to the thread list in the state and the same intent question is asked again. Bound it by turn count in the tag.