TypeSafe Jev for OTP.

Questions are data, the reply is a plain map, and Jev.Server lets a GenServer talk to Jev by replying:

def handle_call({:labels, issue}, from, s) do
  {:reply, {from, issue,
     kind: {"What kind of issue?", %{bug: nil, feature: nil, other: nil}},
     security: "Is this a vulnerability?"}, s}
end

def handle_answer(%{security: p}, from, s) when p > 0.5, do: done(from, [:security], s)
def handle_answer(%{kind: k, confidence: %{kind: c}}, from, s) when c > 0.85, do: done(from, [k], s)
def handle_answer(%{kind: k}, from, s), do: done(from, [k, :"needs-triage"], s)

This module holds the pure half: questions/1 normalizes shorthands into Jev.Noul, Jev.Choice, and Jev.Score structs, and reply/2 turns a decoded API response into the reply map. Jev.HTTP.post/3 is the transport.

The reply map

%{
  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, even when you asked for jev-latest
}

confidence, probabilities, usage, and model are reserved question names.

Summary

Types

Text, a JSON-encodable map or list, or nil.

A question in one of the accepted shorthands

Functions

The cost in USD of input_tokens at the configured price.

Normalizes a keyword list or map of questions into question structs.

Turns a decoded API response body into the reply map.

Types

entry()

@type entry() :: String.t() | map() | list() | nil

Text, a JSON-encodable map or list, or nil.

question()

@type question() :: Jev.Noul.t() | Jev.Choice.t() | Jev.Score.t()

questions()

@type questions() :: %{required(atom()) => question()}

reply()

@type reply() :: %{
  optional(atom()) => atom() | float(),
  confidence: %{required(atom()) => float()},
  probabilities: %{
    required(atom()) => %{required(atom() | non_neg_integer()) => float()}
  },
  usage: usage(),
  model: String.t() | nil
}

shorthand()

@type shorthand() ::
  entry()
  | {entry(), %{required(atom()) => entry()}}
  | {entry(), [entry(), ...]}
  | question()

A question in one of the accepted shorthands:

  • a string or map: Jev.Noul instructions
  • {instructions, %{label => description}}: Jev.Choice
  • {instructions, [level, ...]}: Jev.Score
  • a question struct, passed through

usage()

@type usage() :: %{
  input_tokens: non_neg_integer(),
  output_tokens: non_neg_integer(),
  cost: float()
}

Functions

cost(input_tokens)

@spec cost(non_neg_integer()) :: float()

The cost in USD of input_tokens at the configured price.

Jev bills input tokens only. The price defaults to 0.042 USD per million and can be set with config :jev, usd_per_million_input: 0.042.

questions(questions)

@spec questions(keyword(shorthand()) | %{required(atom()) => shorthand()}) ::
  questions()

Normalizes a keyword list or map of questions into question structs.

iex> Jev.questions(security: "Is this a vulnerability?")
%{security: %Jev.Noul{instructions: "Is this a vulnerability?"}}

iex> Jev.questions(kind: {"What kind?", %{bug: "Broken", other: nil}})
%{kind: %Jev.Choice{instructions: "What kind?", criteria: %{bug: "Broken", other: nil}}}

iex> Jev.questions(severity: {"How severe?", ["Cosmetic", "Blocks"]})
%{severity: %Jev.Score{instructions: "How severe?", criteria: ["Cosmetic", "Blocks"]}}

Raises ArgumentError for an empty list, a reserved name, a choice outside 2..255 options, or a score outside 2..10 levels.

reply(body, questions)

@spec reply(map(), questions()) :: reply()

Turns a decoded API response body into the reply map.

The questions are needed to map labels back to atoms: the criteria keys are the only atoms this function can produce, so no atoms are created from input.