Producer
As mentioned in previous sections, the game logic requires the dynamic generation of two key objects: the Case to be solved and the Hints to guide the player. To handle this, I introduced the Producer[T] type class, which provides ad-hoc polymorphism for any game object T that needs to be generated by the system.
The Producer[T] trait defines a produce method that takes a variable number of Constraints, allowing the generation process to be parameterized and tailored to specific needs. It also features an asynchronous counterpart, produceAsync, designed for long-running tasks such as Large Language Model (LLM) calls, which prevents blocking the application's main thread.
trait Producer[T]:
def produce(constraints: Constraint*): Either[ProductionError, T]
def produceAsync(constraints: Constraint*): Future[Either[ProductionError, T]] = Future {
produce(constraints*)
}
object Producers:
given Producer[Case] = new GroqLLMProducer[Case](apiKey = key)(userPrompt = UserPrompt.Case)
given Producer[Hint] = new GroqLLMProducer[Hint](apiKey = key)(userPrompt = UserPrompt.Hint)The primary implementation of this type class is GroqLLMProducer[T], which leverages the an LLM provided by Groq to generate objects. This producer constructs a request based on the provided constraints, sends it to the LLM, and then parses the JSON response back into an object of type T.
class GroqLLMProducer[T](apiKey: String)
(systemPrompt: SystemPrompt = Base, userPrompt: UserPrompt)
(using parser: ResponseParser[T])
extends BaseLLMClient(apiKey) with GroqProvider with Producer[T]:
import GroqProvider.model
override def produce(constraints: Constraint*): Either[ProductionError, T] =
val params = Seq(Prompt.Parameter(Prompt.Placeholder.Constraints, constraints.map(_.toPromptDescription)))
for
systemPrompt <- systemPrompt.build()
userPrompt <- userPrompt.build(params)
request = GroqRequest(
model = model,
messages = List(
GroqMessage("system", systemPrompt),
GroqMessage("user", userPrompt)
)
)
result <- invoke[T](request)(using parser)
yield resultThis design allows for a clean and decoupled usage. By defining given instances for each generatable type (e.g., Caseand Hint), we can easily invoke the generation logic wherever it is needed, abstracting away the underlying implementation details.
// Usage example: GameInitializationController
override def initGame(theme: Theme, difficulty: Difficulty)
(onSuccess: () => Unit, onError: String => Unit): Unit =
summon[Producer[Case]]
.produceAsync(theme, difficulty)
.onComplete:
case Success(Right(generatedCase)) =>
initializeGameState(generatedCase)
onSuccess()
case Success(Left(error)) => onError(error.message)
case Failure(exception) => onError(s"Unexpected error: ${exception.getMessage}")The implementation, especially the GroqLLMProducer, was tested in isolation to ensure its reliability without making actual, non-deterministic network calls. This was achieved by creating a TestableGroqLLMProducer within the test suite, which overrides the method responsible for the external API call to return a mock response. This allowed for the simulation of both successful outcomes and various failure conditions, such as network errors or parsing issues.
"GroqLLMProducer" should:
"successfully produce case when all components work" in:
val producer = new TestableGroqLLMProducer[Case](
apiKey = "test-key",
mockResponse = Right("valid response"),
testSystemPrompt = MockPrompt("Test system prompt"),
testUserPrompt = MockPrompt("Test user prompt")
)
val result = producer.produce(Theme("noir"))
result shouldBe a[Right[_, _]]
result.value.plot.title shouldBe "Test Mystery"