Skip to content

Typed Queries

A JevQuery declares questions as properties. Each property name becomes the question id, and each property is a typed handle (QuestionRef<A>) that reads its answer back from a result.

object Triage : JevQuery() {
    val urgent by noul("Does this message convey urgency or time-sensitivity?") {
        whenTrue("Explicitly time-sensitive, or the customer is blocked right now")
        whenFalse("No urgency expressed")
    }
    val team by choice<Team>("Which team should handle this message?")
    val frustration by score("How frustrated does the customer appear?") {
        level("Calm, just stating facts")
        level("Frustrated but civil")
        level("Very angry, strong language or threatening to leave")
    }
}

Team is an ordinary enum; see Enum Choices.

Asking and reading

jev.ask(query, state) sends every question in the query as one request. result[handle] returns the answer, already typed:

val result = jev.ask(Triage, state = ticket)

val urgent: NoulAnswer = result[Triage.urgent]
val team: ChoiceAnswer<Team> = result[Triage.team] // team.choice is a Team
val frustration: ScoreAnswer = result[Triage.frustration]

println("${team.choice} urgent=${urgent.noul} frustration=${frustration.normalized}")

A handle only works on results of requests that included it. Reading one from an unrelated result throws IllegalArgumentException.

The builder functions

A JevQuery has the same builders as the inline DSL, minus the id argument, which comes from the property name:

Function Returns a handle to
noul(instructions) { ... } NoulAnswer
choice(instructions) { ... } ChoiceAnswer<String>
choice<E>(instructions) ChoiceAnswer<E>
score(instructions) { ... } ScoreAnswer

Custom ids

id = sends a different id than the property name:

object Sentiment : JevQuery() {
    // The property name is the default id; `id =` sends something else.
    val tone by choice("What is the customer's tone?", id = "customer_tone") {
        options("calm", "frustrated", "angry")
    }
}

Inheritance

Queries can extend other queries. The base class's questions come first, then the subclass's, each in declaration order:

open class BaseChecks : JevQuery() {
    val spam by noul("Is this message unsolicited advertising or spam?")
    val abusive by noul("Does this message contain abusive or threatening language?")
}

// Subclass questions come after the base class's, in declaration order.
object ForumPostChecks : BaseChecks() {
    val offTopic by noul("Is the post unrelated to software development?")
}

Parameterized queries

A query can be a class, so its questions can depend on runtime values. Here there's one query per policy:

// A query can be a class, so its questions can depend on runtime values.
class PolicyCheck(
    policy: String,
) : JevQuery() {
    val violates by noul("Does the message violate this policy: $policy")
    val severity by score("How serious is the violation of this policy: $policy") {
        levels("No violation", "Minor, a reminder is enough", "Serious, needs moderator action")
    }
}

val policyChecks = listOf("No personal data", "No medical advice").map(::PolicyCheck)

suspend fun policyViolations(
    jev: JevApi,
    message: String,
): List<Double> = policyChecks.map { check -> jev.ask(check, state = message)[check.violates].noul }

Combining with ad-hoc questions

include() inside an inline query asks a typed query's questions alongside one-off questions, in one request:

// Mix a typed query with ad-hoc questions in one request; the typed handles still work.
val result =
    jev.query(state = ticket) {
        include(Triage)
        noul("mentions_competitor", "Does the message mention a competing product?")
    }
val team = result[Triage.team].choice
val competitor = result.noul("mentions_competitor").noul

Many states

The same query works for any number of states. Here they run concurrently:

// The same query works for any number of states; run them concurrently if you like.
suspend fun triageAll(
    jev: JevApi,
    tickets: List<String>,
): Map<String, Team> =
    coroutineScope {
        tickets
            .map { ticket -> async { ticket to jev.ask(Triage, state = ticket)[Triage.team].choice } }
            .awaitAll()
            .toMap()
    }

Keep concurrency modest on a shared key; see Making Calls.

When definitions are checked

A query's questions are validated the first time they're used, typically on the first ask, not when the object is created. Invalid definitions, such as blank instructions or duplicate ids, surface as a JevValidationException listing every problem.