Skip to content

Repository files navigation

Jev

Hex.pm Documentation CI

TypeSafe Jev and compatible decision models for OTP.

Jev is a peer process. You reply to it from a GenServer, and its answer is a message you pattern match on.

defmodule Triage do
  use Jev.Server

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

  def handle_call({:labels, issue}, from, s) do
    {:reply, {from, issue,
       kind: {"What kind of issue is this?", %{bug: "Broken", feature: "New behavior", other: nil}},
       severity: {"How severe for users?", ["Cosmetic", "Workaround", "Blocks", "Data loss"]},
       security: "Does this describe a vulnerability?"}, s}
  end

  # Clause order is the routing. Thresholds are guards.
  def handle_answer(%{security: p}, from, s) when p > 0.5, do: done(from, [:security], s)

  def handle_answer(%{kind: :bug, severity: sev, confidence: %{kind: c}}, from, s)
      when c > 0.85 and sev >= 2,
      do: done(from, [:bug, :"priority:high"], s)

  def handle_answer(%{kind: k, confidence: %{kind: c}}, from, s) when c > 0.6, do: done(from, [k], s)
  def handle_answer(%{kind: k}, from, s), do: done(from, [k, :"needs-triage"], s)
  def handle_answer({:error, reason}, from, s), do: done(from, {:error, reason}, s)

  defp done(from, result, s) do
    GenServer.reply(from, result)
    {:noreply, s}
  end
end

Nothing below handle_answer/3 touches the network, so tests call it with a literal map. The server never blocks on Jev: a hundred calls can be in flight, and each answer finds its clause when it lands.

Installation

def deps do
  [{:jev, "~> 0.1"}]
end
config :jev, api_key: System.get_env("TYPESAFE_API_KEY")   # or just set TYPESAFE_API_KEY

Or, without a key, point at a self-hosted model that speaks the same wire format, see Endpoints.

Requires Elixir 1.18 or later, for the built-in JSON module, and Erlang/OTP 27 or later.

Documentation

Questions

Three structs, one per TypeSafe primitive, and a shorthand for each, told apart by the shape of the criteria:

security: "Is this a vulnerability?"                                          # string   → Jev.Noul
kind:     {"What kind of issue?", %{bug: "Broken", feature: nil, other: nil}}  # {q, map}  → Jev.Choice
severity: {"How severe?", ["Cosmetic", "Workaround", "Blocks", "Data loss"]}   # {q, list} → Jev.Score
urgent:   %Jev.Noul{instructions: "Needs attention now?",
                    criteria: %{true: "Users blocked", false: "Workaround exists"}}

Every field the API accepts as JSON is a map or list in the struct. Structured instructions and rubrics need nothing special:

wrong: %Jev.Noul{instructions: %{field: spec, extracted_value: value,
                                 question: "Is `extracted_value` unsupported by the text given `field`?"}}

kind: {"What is the request?", %{
  billing:  %{what: "Charges, refunds", not_for: "Order tracking", examples: ["Charged twice"]},
  shipping: %{what: "Delivery status", examples: ["Where is my order"]}}}

State is any JSON-encodable term. For your own structs, derive with a field list so trimming irrelevant state, which Jev is sensitive to, is declarative:

@derive {JSON.Encoder, only: [:url, :title, :text]}
defstruct [:url, :title, :text, :dom, :headers]

The reply

A plain map, so callbacks match on question names directly:

%{
  kind: :bug,            # Choice → label atom
  severity: 2.4,         # Score  → expected level, float
  security: 0.03,        # Noul   → probability of yes
  confidence:    %{kind: 0.91, severity: 0.62},
  probabilities: %{kind: %{bug: 0.93, feature: 0.04, other: 0.03},
                   severity: %{0 => 0.1, 1 => 0.1, 2 => 0.2, 3 => 0.6}},
  usage: %{input_tokens: 812, output_tokens: 0, cost: 3.4e-5},
  model: "jev-1.13.0"    # the concrete model that answered
}

confidence, probabilities, usage, and model are reserved question names. Labels come back as atoms safely: the criteria keys are the only atoms the parser can produce.

Everything you might want on top is the standard library:

with %{kind: k, confidence: %{kind: c}} when c > 0.85 <- reply, do: act(k)

[first, second | _] = reply.probabilities.kind |> Enum.sort_by(&elem(&1, 1), :desc)
level = round(reply.severity)

Jev.Server

A GenServer that owns the real callbacks and delegates to yours, the way GenStage and Agent are built. One new callback:

handle_answer(reply | {:error, reason}, tag, state)

Message, tag, state mirrors handle_call's message, from, state. From any callback, {:reply, {tag, state, questions}, s} sends to Jev. The request runs under a Task.Supervisor, so a crashed request becomes {:error, reason} in handle_answer/3 instead of taking the server down. Per-request options such as model: go in a fourth element, with the questions in their own brackets.

Recursion is a handle_answer clause that replies again. Context rides in the tag, the base case is a clause, the bound is a guard:

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

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

Without a server

Jev.HTTP.post/3 is the transport and works on its own for scripts and evaluation harnesses:

{:ok, reply} = Jev.HTTP.post(issue, kind: {"Kind?", %{bug: nil, other: nil}}, security: "Vuln?")

It retries 429 and 529 with backoff, honouring Retry-After. Non-2xx responses come back as {:error, %Jev.Error{status: status, body: body, request_id: id}}, and a 200 whose body does not fit the wire format as {:error, %JSONCodec.Error{}}. The wire format itself is Jev.Wire, a set of JSONCodec structs; Jev.Wire.Response.schema/0 is its JSON Schema.

Telemetry

Event Measurements Metadata
[:jev, :request, :start] system_time backend, endpoint, model, questions, state_hash, tag
[:jev, :request, :stop] duration, input_tokens, output_tokens, cost plus status, request_id, confidence
[:jev, :request, :exception] duration plus kind, reason, stacktrace
[:jev, :answer] confidence, probability name, type, answer, plus the start metadata

The state is never in metadata, only its hash. A distribution on jev.answer.confidence tagged by name is a calibration monitor:

distribution("jev.answer.confidence", tags: [:name], reporter_options: [buckets: [0.5, 0.7, 0.85, 0.95]]),
sum("jev.request.stop.cost"),
summary("jev.request.stop.duration", unit: {:native, :millisecond})

Cost is input tokens at config :jev, usd_per_million_input: 0.042, Jev's published price. It bills no output tokens. Named endpoints carry their own price, zero unless configured.

Configuration

config :jev,
  api_key: "...",                      # default: TYPESAFE_API_KEY
  base_url: "https://api.typesafe.ai",
  model: "jev-latest",
  max_retries: 3,
  receive_timeout: 30_000,
  usd_per_million_input: 0.042,
  req_options: []                      # merged into Req.new/1

In tests, point the transport at a Req.Test plug and answer with Jev.Test, which writes the response the way you read a reply:

config :jev, api_key: "test", req_options: [plug: {Req.Test, Jev.HTTP}, retry_delay: 0]

Req.Test.stub(Jev.HTTP, &Jev.Test.respond(&1, kind: :bug, security: 0.03))

Endpoints

The /v1/systemone wire format has become the common shape for decision models. Open ones such as Laya, kev, decider, and jeff serve it, and JevBench ranks them against Jev. Name them, and pick one per call:

config :jev,
  endpoints: [
    laya: [base_url: "http://localhost:8000"],
    jeff: [base_url: "https://jeff.internal", api_key: "...", model: "gliformer-large",
           usd_per_million_input: 0.007]
  ]

{:reply, {tag, state, [kind: @kinds], [endpoint: :laya]}, s}   # from a Jev.Server
Jev.HTTP.post(state, questions, endpoint: :laya)             # directly

The top-level configuration is the :typesafe endpoint and the default; config :jev, endpoint: :laya makes a named one the default, which is how a development environment runs without a key. A named endpoint never inherits the TypeSafe key or price: with no api_key it sends no Authorization header, and its cost is zero unless it has a usd_per_million_input. Transport settings (max_retries, receive_timeout, req_options) are inherited and can be overridden. Jev.HTTP.endpoint/1 shows the result.

Replies from any endpoint have the same shape. When a server omits confidence, it is computed from the probabilities the way TypeSafe defines it. Models are calibrated differently, so a threshold tuned on one is a starting point on another; the [:jev, :answer] telemetry event carries the endpoint, so one histogram per endpoint shows the difference.

An endpoint is still HTTP. backend: swaps the transport itself for any module implementing Jev.Backend, one callback, for an in-process model or a fake; config :jev, backend: sets the default. Jev.HTTP is the backend that speaks the wire format. Both share Jev.Telemetry, so the events below are the same whatever answered.

The natural use is a cascade. Ask the local model first and escalate to Jev when it is unsure:

def handle_call({:classify, text}, from, s),
  do: {:reply, {{:local, from, text}, text, [kind: @kinds], [endpoint: :laya]}, s}

def handle_answer(%{confidence: %{kind: c}}, {:local, from, text}, s) when c < 0.7,
  do: {:reply, {{:jev, from, text}, text, kind: @kinds}, s}

def handle_answer(%{kind: k}, {_stage, from, _text}, s), do: done(from, k, s)

Development

mix deps.get
mix ci
TYPESAFE_API_KEY=... mix run examples/triage.exs   # live smoke test

About

TypeSafe Jev for OTP: reply to Jev from a GenServer and pattern match on its answer

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages