Typed questions

The service offers exactly three question types. This page is about getting answers back in your types rather than in strings and numbers.

The three shapes

Noul is a yes/no question, answered with the probability that the answer is yes. It carries no confidence — the probability is the answer.

Question.noul("Does this convey urgency?")
Question.noul("Is this urgent?", ifTrue = "Time-sensitive", ifFalse = "Routine")

Choice picks one option from a set you define, and returns the chosen option, the full distribution, and a confidence.

Score rates against ordered levels and returns a probability-weighted value, which can land between levels — 1.05 means "mostly level 1, leaning towards 2".

Options from an enum

The natural Scala spelling of "one of a closed set" is an enum, so Choice derives from one:

enum Dept derives Options:
  case Billing, Technical, Sales

val q = Question.choice[Dept]("Which team should handle this?")

The case labels become the option names, and the answer comes back as a Dept, not a String. Add rubric text where the names are not self-explanatory:

val options = Options.describing[Dept](
  Map("Billing" -> Instructions.text("Payments, invoicing, refunds").toOption.get)
)

Cardinality is checked when you declare the enum, not when you send the request: an enum with more than 255 cases fails to compile.

For options only known at runtime:

Question.choiceOfStrings("Which team?", teams.map(_ -> None))

Levels from an enum

enum Frustration derives Levels:
  case Calm, Frustrated, VeryAngry

Question.scoreOf[Frustration]("How frustrated is the customer?")

Declaration order is rubric order, lowest first, and it is what the service's level indices refer to. Reordering the cases changes the meaning of every score you have already recorded, so treat that order as part of your data model. An enum with fewer than two or more than ten cases fails to compile.

Asking several at once

val (urgent, team, mood) = client.ask(
  Ask(
    Question.noul("Does this convey urgency?"),
    Question.choice[Dept]("Which team should handle this?"),
    Question.score("How frustrated?", Seq("Calm", "Frustrated", "Very angry"))
  ),
  ticket
)

Answers come back positionally at exactly the right types. Question keys are generated for you (q00, q01, …); use withKeys if they need to match something else, and withModel to pin one request to a specific model.

Your own answer types

Question[A] is a functor over its answer, so you can fold post-processing into the question itself and have your own type appear in the tuple:

final case class Route(team: Dept, escalate: Boolean)

def route: Question[Route] =
  Question.choice[Dept]("Which team should handle this?")
    .map(a => Route(a.choice, escalate = (a.confidence.value: Double) < 0.7))

emap does the same but can reject an answer your type cannot represent; the rejection surfaces as a Verdict4sError exactly as a malformed response would.

The wire request is unchanged by either — only the way the answer is read.

When the questions are not known statically

Drop to the map-based API:

val evaluation: Evaluation = client.evaluate(request)

evaluation.answers.foreach { (key, answer) =>
  answer match
    case Answer.Noul(p)                       => ???
    case Answer.Choice(choice, probs, conf)   => ???
    case Answer.Score(score, legend, _, conf) => ???
}

A question type this release does not model

If the service grows a fourth type, you do not have to wait for a release:

Question.raw[MyAnswer]("ranking", body) { answer => ??? }