Case Generation
In the project, I primarily worked on the investigative case generation subsystem. This includes the domain types that represent a case (Case, Entity, Constraint) and the LLM integration infrastructure that supports the generation pipeline (BaseLLMClient, GroqProvider, GroqLLMProducer, Prompt, ResponseParser).
Core Domain Entities
The foundation of the case generation system consists of the core domain entities: Case, Entity (with its variants Character, CaseFile, CustomEntity), and Constraint.
These components are modeled following functional programming principles, prioritizing immutability and type safety through Algebraic Data Types.
All domain components are implemented as immutable case classes. The Case trait exposes a read-only interface:
trait Case:
def plot: Plot
def characters: Set[Character]
def caseFiles: Set[CaseFile]
def solution: SolutionThe concrete implementation, CaseImpl, is declared private[model] to enforce controlled construction through the factory method:
private[model] final case class CaseImpl(
plot: Plot,
characters: Set[Character],
caseFiles: Set[CaseFile],
solution: Solution
) extends Case derives ReadWriterThe Case companion object provides the factory method for controlled construction:
object Case:
def apply(constraints: Constraint*)(using producer: Producer[Case]): Either[ProductionError, Case] =
producer.produce(constraints*)This design choice serves two purposes: it ensures all Case instances are created through the validated factory method (Case.apply), preventing arbitrary instantiations that might violate domain invariants.
The entities composing a case are modeled as an Algebraic Data Type using a sealed trait with final case class constructors:
sealed trait Entity derives ReadWriter
final case class Character(name: String, role: CaseRole) extends Entity
final case class CaseFile(
title: String,
content: String,
kind: CaseFileType,
sender: Option[Character],
receiver: Option[Character],
date: Option[String]
) extends Entity
final case class CustomEntity(entityType: String) extends EntityThe sealed modifier creates a closed sum type known to the compiler, enabling exhaustiveness checking in pattern matching.
When matching on Entity, the compiler verifies all cases are handled and produces warnings if a case is missing. This eliminates entire classes of runtime errors, if we add a new entity type, all existing pattern matches become compilation errors until updated.
For finite domains like CaseRole and CaseFileType, I used Scala enum syntax:
enum CaseRole derives ReadWriter:
case Suspect
case Victim
case Witness
case Investigator
case Accomplice
case Informant
enum CaseFileType derives ReadWriter:
case Message
case Email
case Interview
case Diary
case TextDocument
case NotesConstraint
The Constraint system demonstrates the power of ADTs combined with extension methods for domain-specific behavior. Constraints guide the case generation process by specifying user requirements:
sealed trait Constraint
enum Difficulty(val difficulty: String) extends Constraint:
case Easy extends Difficulty("Easy")
case Medium extends Difficulty("Medium")
case Hard extends Difficulty("Hard")
final case class Theme(value: String) extends Constraint
final case class CharactersRange(min: Int, max: Int) extends Constraint
final case class CaseFilesRange(min: Int, max: Int) extends Constraint
final case class PrerequisitesRange(min: Int, max: Int) extends Constraint
final case class Context(content: String) extends ConstraintThe sealed ADT structure ensures type safety. We cannot create invalid constraints and pattern matching is exhaustively checked by the compiler. The architecture is designed for extensibility: Case.apply accepts multiple constraints (Constraint*), allowing future additions without modifying the core generation logic.
Extension methods allow adding behavior to the Constraint type without modifying the domain model itself:
object Constraint:
extension (c: Constraint)
def toPromptDescription: String = c match
case Theme(value) => s"Theme: $value"
case CharactersRange(min, max) => s"Number of characters: between $min and $max"
case CaseFilesRange(min, max) => s"Number of case files: between $min and $max"
case PrerequisitesRange(min, max) => s"Solution prerequisites: between $min and $max"
case HintKind.Helpful => s"The hint to be generated must be ${HintKind.Helpful.toString}"
case HintKind.Misleading => s"The hint to be generated must be ${HintKind.Misleading.toString}"
case Context(content) => s"Additional context:\n\n $content"
case difficulty: Difficulty => formatDifficultyConstraints(difficulty)The toPromptDescription method enables constraints to be transformed into natural language descriptions that are inserted into the LLM prompts. This design makes the constraint system both type-safe (through ADTs) and flexible (through extension methods), allowing easy addition of new constraint types without breaking existing code.
LLM Integration
BaseLLMClient
The architecture is built around BaseLLMClient, which defines a provider interface using abstract type members:
abstract class BaseLLMClient(protected val apiKey: String):
type Request
final def invoke[T](req: Request)(using parser: ResponseParser[T]): Either[ProductionError, T] =
for
rawResponse <- makeCall(req)
res <- parser.parse(rawResponse)
yield res
protected def makeCall(req: Request): Either[ProductionError, String)The type Request declaration is an abstract type member, a type that's declared but not defined. This pattern enables family polymorphism: the abstract class defines a family of related types (the LLM client and its request format), but leaves specific members abstract for concrete implementations to define.
This approach has several advantages over generic type parameters (BaseLLMClient[Req]):
- Cleaner signatures: No type parameter pollution in the class declaration
- Late binding: The type is defined where it's most natural (the provider implementation), not at the inheritance point
- Encapsulation: The request type becomes part of the provider's implementation details
The invoke method uses a for-comprehension to compose the API call and parsing steps.
This is syntactic sugar for monadic composition:
makeCall(req).flatMap { rawResponse =>
parser.parse(rawResponse)
}The Either monad enables that: each step can succeed (Right) or fail (Left). The for-comprehension automatically handles the branching—if any step returns Left, execution short-circuits and the error propagates without executing subsequent steps. If all steps succeed, the final yield produces the result. This eliminates nested error checking while making failure modes explicit in the type signature.
GroqProvider
The GroqProvider trait implements the Groq-specific logic as a mixin using a self-type constraint:
trait GroqProvider:
self: BaseLLMClient =>
type Request = GroqRequest
protected def makeCall(req: Request): Either[ProductionError, String] =
/*Implementation of the Groq API call*/
protected case class GroqRequest(
messages: List[GroqMessage],
model: String,
temperature: Double = 0.7,
max_tokens: Int = 4000
)The self-type annotation self: BaseLLMClient => creates a compile-time constraint: this trait can only be mixed into classes that extend BaseLLMClient. This is not inheritance—we're not saying GroqProvider is a BaseLLMClient, but rather that it requires being composed with one. This gives us access to BaseLLMClient's protected members (like apiKey) while keeping GroqProvider as a composable module.
The concrete Request type is defined here (type Request = GroqRequest), resolving the abstract type from BaseLLMClient. This creates a type-safe connection: the makeCall implementation works with GroqRequest, and the compiler ensures this matches the abstract Request type expected by BaseLLMClient.
GroqLLMProducer
The final composition in GroqLLMProducer brings everything together:
class GroqLLMProducer[T](apiKey: String)
(systemPrompt: SystemPrompt = Base, userPrompt: UserPrompt)
(using parser: ResponseParser[T])
extends BaseLLMClient(apiKey)
with GroqProvider
with Producer[T]:
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 resultScala allow composes multiple traits into a single class following a well-defined order.
The GroqLLMProducer:
- Extends
BaseLLMClientto get the template methodinvoke - Mixes in
GroqProviderto get the concreteRequesttype andmakeCallimplementation - Mixes in
Producer[T]to satisfy the production interface
The self-type constraint in GroqProvider is satisfied because GroqLLMProducer extends BaseLLMClient.
If we tried to mix GroqProvider into a class not extending BaseLLMClient, the code wouldn't compile—this is static, compile-time safety.
Prompt System
The prompt system constructs LLM requests by loading templates from files and substituting constraint values. The Prompt trait provides the core functionality:
trait Prompt(path: String):
def build(params: Seq[Prompt.Parameter] = Seq.empty): Either[ProductionError, String] =
loadTemplate(path).map { template =>
params.foldLeft(template) { case (acc, parameter) =>
acc.replace(parameter.placeholder.tag, parameter.values.mkString("\n"))
}
}The key pattern here is using foldLeft to apply successive transformations. Given a template with placeholders like , the function iterates through parameters and replaces each placeholder with its corresponding values. This is a functional accumulation pattern—no mutable variables, just pure transformation of strings.
object Prompt:
final case class Parameter(placeholder: Placeholder, values: Seq[String])
enum Placeholder(val tag: String):
case Constraints extends Placeholder("{{CONSTRAINTS}}")
case None extends Placeholder("")The constraint-to-prompt transformation uses the toPromptDescription extension method defined earlier, creating a pipeline: constraints → descriptions → parameters → substituted template.
Prompts are organized using enums that extend the Prompt trait:
enum SystemPrompt(path: String) extends Prompt(path):
case Base extends SystemPrompt("/prompts/system/base.md")
enum UserPrompt(path: String) extends Prompt(path):
case Case extends UserPrompt("/prompts/user/case-generation.md")
case Hint extends UserPrompt("/prompts/user/hint-generation.md")This design demonstrates enum inheritance: the enum cases inherit behavior from Prompt while providing specific template paths. The separation between SystemPrompt (defines the LLM's role) and UserPrompt (specifies the task) follows the standard LLM prompting pattern. Adding a new prompt type requires only adding an enum case—the template loading and substitution logic is inherited automatically.
Response Parsing
The response parsing system uses type classes to achieve ad-hoc polymorphism without inheritance:
trait ResponseParser[T]:
def parse(jsonString: String): Either[ProductionError, T]
object ResponseParser:
given ResponseParser[Case] with
override def parse(jsonString: String): Either[ProductionError, Case] =
parseString[CaseImpl](jsonString)
given ResponseParser[Hint] with
override def parse(jsonString: String): Either[ProductionError, Hint] =
parseString[HintImpl](jsonString)
private def parseString[T: Reader](jsonString: String): Either[ProductionError, T] =
Try(read[T](jsonString)).toEither.left.map { e =>
ProductionError.ParseError(s"Unexpected error during parsing: ${e.getMessage}\n${e.getStackTrace.mkString("\n")}")
}The type class pattern consists of three components:
- Type class trait:
ResponseParser[T]defines the interface - Type class instances:
givendefinitions provide implementations for specific types - Type class constraints:
usingparameters require instances at call sites
The parseString helper uses a context bound [T: Reader], which is syntactic sugar for [T](using reader: Reader[T]). This is a contextual parameter—the compiler automatically searches for and injects a Reader[T] instance from the implicit scope.
The critical implementation detail is that these Reader instances are not manually written—they're automatically generated by the compiler:
private[model] final case class CaseImpl(...) extends Case derives ReadWriterThe derives keyword triggers automatic derivation: compile-time metaprogramming where the compiler generates serialization/deserialization logic based on the case class structure. This is a form of compiler as a tool—we declare what we want (a serializable case class), and the compiler generates how to achieve it.
This eliminated hundreds of lines of manual parsing code that existed in the previous architecture. The old system required:
- Intermediate DTO (Data Transfer Object) classes
- Manual field-by-field copying from JSON to domain objects
- Error handling for each field
The new design is purely declarative: we declare that CaseImpl should be serializable (derives ReadWriter), and the compiler does the rest. This demonstrates the power of type-driven development—shifting complexity from runtime (error-prone manual code) to compile-time (verified code generation).