Powered by AppSignal & Oban Pro

Jidoka: Structured Output

livebook/21_structured_output.livemd

Jidoka: Structured Output

Run in Livebook

Build a ticket classifier whose normal chat result is a validated Elixir map. The notebook tests the output contract without a provider first, then uses the same agent in an optional provider-backed chat cell.

Setup

local_checkout? = File.exists?(Path.expand("../mix.exs", __DIR__))

jidoka_dep =
  if local_checkout? do
    {:jidoka, path: Path.expand("..", __DIR__)}
  else
    {:jidoka, git: "https://github.com/agentjido/jidoka.git", branch: "main"}
  end

Mix.install(
  [
    jidoka_dep,
    {:kino, "~> 0.19.0"}
  ],
  config: [
    jidoka: [
      model_aliases: %{fast: "anthropic:claude-haiku-4-5"}
    ]
  ],
  force: !local_checkout?
)
Jidoka.Kino.setup()

Define A Typed Classifier

The output do block is part of the agent contract. Jidoka appends output instructions to the model request, then validates the completed final answer before returning it to the caller.

defmodule LivebookDemo.StructuredOutput.TicketClassifier do
  use Jidoka.Agent

  agent do
    id :livebook_ticket_classifier

    output do
      schema Zoi.object(%{
        category: Zoi.enum([:billing, :technical, :account]),
        confidence: Zoi.float(),
        summary: Zoi.string()
      })

      retries 1
      on_validation_error :repair
    end
  end

  defaults do
    model :fast
    instructions """
    Classify support tickets for routing.
    Keep summaries short and user-facing.
    """
  end
end

Inspect the compiled output contract.

{:ok, definition} = Jidoka.inspect_agent(LivebookDemo.StructuredOutput.TicketClassifier)

%{
  id: definition.id,
  output: definition.output,
  schema: LivebookDemo.StructuredOutput.TicketClassifier.output_schema()
}

Test The Contract Without A Provider

You can parse and validate a sample final answer directly. This is useful when you are designing the schema before making any model calls.

output = LivebookDemo.StructuredOutput.TicketClassifier.output()

Jidoka.Output.parse(
  output,
  ~s({"category":"billing","confidence":0.94,"summary":"Customer reports a duplicate charge."})
)

The runtime finalizer is also testable without a provider by completing a synthetic request and letting Jidoka.Output replace the raw answer.

request_id = "structured-output-demo"

agent =
  LivebookDemo.StructuredOutput.TicketClassifier.runtime_module()
  |> then(& &1.new(id: "structured-output-demo-agent"))
  |> Jido.AI.Request.start_request(request_id, "Classify a billing issue")
  |> Jido.AI.Request.complete_request(
    request_id,
    ~s({"category":"billing","confidence":0.88,"summary":"Invoice was charged twice."})
  )

agent = Jidoka.Output.finalize(agent, request_id, output)

Jido.AI.Request.get_result(agent, request_id)

Import The Same Shape

JSON and YAML imported agents cannot embed Zoi terms, so they use an object-shaped JSON Schema subset.

spec = %{
  "agent" => %{"id" => "imported_ticket_classifier"},
  "defaults" => %{
    "model" => "fast",
    "instructions" => "Classify support tickets for routing."
  },
  "output" => %{
    "schema" => %{
      "type" => "object",
      "required" => ["category", "confidence", "summary"],
      "properties" => %{
        "category" => %{"type" => "string"},
        "confidence" => %{"type" => "number"},
        "summary" => %{"type" => "string"}
      }
    },
    "retries" => 1,
    "on_validation_error" => "repair"
  }
}

{:ok, imported} = Jidoka.import_agent(spec)

Map.take(Jidoka.ImportedAgent.definition(imported), [:id, :output])

Optional Provider Chat

With an ANTHROPIC_API_KEY Livebook secret configured, the normal chat call returns a parsed map. Use output: :raw when you want to inspect the unparsed assistant answer during debugging.

{:ok, pid} =
  Jidoka.Kino.start_or_reuse("livebook-ticket-classifier", fn ->
    LivebookDemo.StructuredOutput.TicketClassifier.start_link(id: "livebook-ticket-classifier")
  end)

Jidoka.Kino.chat("Structured classifier chat", fn ->
  LivebookDemo.StructuredOutput.TicketClassifier.chat(
    pid,
    "I was double charged for my last invoice and need help."
  )
end)
Jidoka.Kino.chat("Raw classifier answer", fn ->
  LivebookDemo.StructuredOutput.TicketClassifier.chat(
    pid,
    "I cannot reset my password.",
    output: :raw
  )
end)