Part 2: typed answers

Before anyone answers a message, the shop wants to know what it is about and how urgent it is, so that billing problems reach the billing team first. This part asks the model for a Ticket, a case class, instead of text.

The code is Part2TypedAnswers.scala. It uses llm4s-schema-derivation, which the samples already depend on.

Describe the answer as a type

derives SchemaOf gives a type its JSON schema; derives ReadWriter (uPickle) reads the model’s JSON into it. The @description annotations go into the schema, where the model reads them.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.llm4s.schema.*
import upickle.default.ReadWriter

enum Category derives SchemaOf, ReadWriter {
  case Billing, Delivery, Returns, Other
}

@description("A customer's message to the support team, classified")
case class Ticket(
  @description("What the message is about") category: Category,
  @description("From 1 (can wait) to 5 (urgent)") urgency: Int,
  @description("The order number, for example A-1001, if the message gives one") orderId: Option[String],
  @description("The problem in one sentence") summary: String
) derives SchemaOf, ReadWriter

Category is an enum, so the schema limits the field to its four case names. orderId is an Option: the model may send null when the customer gave no order number.

Ask for a Ticket

completeStructuredOf[Ticket] sends the derived schema with the request and reads the reply into a Ticket. The provider enforces the schema where it can (OpenAI, Gemini), and gets it as an instruction where it cannot.

1
2
3
4
5
6
7
8
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.{ Conversation, SystemMessage, UserMessage }
import org.llm4s.types.Result

def classify(client: LLMClient, message: String): Result[Ticket] =
  client.completeStructuredOf[Ticket](
    Conversation(Seq(SystemMessage("Classify this message to Acme Shop's support team."), UserMessage(message)))
  )

A reply that is not a Ticket, such as an unknown category, is a Left, never a half-filled value.

Check what the schema cannot promise

The descriptions say the urgency is from 1 to 5, but a provider that only reads the schema as an instruction can still answer 9. Check the values your program relies on before you use them:

1
2
3
4
5
6
7
8
9
10
11
import org.llm4s.error.ValidationError

def checked(ticket: Ticket): Result[Ticket] =
  Either.cond(
    1 <= ticket.urgency && ticket.urgency <= 5,
    ticket,
    ValidationError.invalid("urgency", s"${ticket.urgency} is outside 1 to 5")
  )

def triage(client: LLMClient, message: String): Result[Ticket] =
  classify(client, message).flatMap(checked)

Run the part:

1
2
sbt "samples/runMain org.llm4s.samples.tutorial.supportassistant.Part2TypedAnswers"
sbt "samples/runMain org.llm4s.samples.tutorial.supportassistant.Part2TypedAnswers --live"

Exercise 2: extract a refund request

When a message asks for a refund, the refunds team needs the order, the amount and the reason. RefundRequest is already declared, with its schema, in Exercises.scala. Write Part2Exercise.refundRequest(client, message): ask for a RefundRequest with completeStructuredOf, and refuse an amount that is not above zero with ValidationError.invalid("amount", ...). Then run

1
sbt "samples/testOnly *Part2Spec"

The solution is Part2Solution in the solutions package.

Previous: Part 1, a first call. Next: Part 3, tools and an agent.