Skip to content

Email extraction

This example builds an email-analysis pipeline that classifies an email, pulls out structured entities, summarizes it, and decides what follow-up actions are needed. It demonstrates signatures over enums and case classes, and composing four ChainOfThought steps into one program that returns a single result.

Enums and models

enum EmailType derives Schema:
  case order_confirmation, support_request, meeting_invitation, newsletter,
    promotional, invoice, shipping_notification, other

An enum that derives Schema crosses the signature boundary as a wire string. EmailType and UrgencyLevel are produced as outputs by one step and consumed as inputs by later steps, so the value remains an enum throughout. Case classes such as ExtractedEntity derive Schema the same way and travel as JSON.

Signatures

trait ClassifyEmail extends Spec:
  def email_subject: InputField[String]
  def email_body: InputField[String]
  def sender: InputField[String]
  def email_type: OutputField[EmailType]
  def urgency: OutputField[UrgencyLevel]
  def reasoning: OutputField[String]

Each signature is a trait extending Spec with InputField and OutputField members. Field types carry the structure: outputs can be enums (EmailType), lists of case classes (List[ExtractedEntity]), or optional values (Option[Double]). The pipeline uses four signatures: ClassifyEmail, ExtractEntities, SummarizeEmail, and GenerateActionItems.

Composing the steps

final class EmailProcessor:
  private val classifier      = ChainOfThought(Signature.of[ClassifyEmail])
  private val entityExtractor = ChainOfThought(Signature.of[ExtractEntities])
  private val actionGenerator = ChainOfThought(Signature.of[GenerateActionItems])
  private val summarizer      = ChainOfThought(Signature.of[SummarizeEmail])

  def forward(
      emailSubject: String,
      emailBody   : String,
      sender      : String = ""
  )(using RuntimeContext): Either[DspyError, EmailAnalysis] =
    for
      // Step 1: Classify the email
      classification <- classifier((
                          email_subject = emailSubject,
                          email_body = emailBody,
                          sender = sender
                        ))
      // Step 2: Extract entities
      fullContent = s"Subject: $emailSubject\n\nFrom: $sender\n\n$emailBody"
      entities   <- entityExtractor((
                    email_content = fullContent,
                    email_type = classification.output.email_type
                  ))
      // Step 3: Generate summary
      summary <- summarizer((
                   email_subject = emailSubject,
                   email_body = emailBody,
                   key_entities = entities.output.key_entities
                 ))
      // Step 4: Determine actions
      actions <- actionGenerator((
                   email_type = classification.output.email_type,
                   urgency = classification.output.urgency,
                   email_summary = summary.output.summary,
                   extracted_entities = entities.output.key_entities
                 ))
    // Step 5: Structure the results
    yield EmailAnalysis(
      email_type = classification.output.email_type,
      urgency = classification.output.urgency,
      summary = summary.output.summary,
      key_entities = entities.output.key_entities,
      financial_amount = entities.output.financial_amount,
      important_dates = entities.output.important_dates,
      action_required = actions.output.action_required,
      action_items = actions.output.action_items,
      deadline = actions.output.deadline,
      priority_score = actions.output.priority_score,
      reasoning = classification.output.reasoning,
      contact_info = entities.output.contact_info
    )

EmailProcessor holds one ChainOfThought per signature, built with Signature.of[...]. Each call returns Either[DspyError, ...], so forward threads the steps through a for-comprehension: a Left from any step short-circuits, and the success path reads fields from each step's output to assemble the final EmailAnalysis. The classification result feeds entity extraction, both feed the summary, and the three together feed action generation.

Running it

OPENAI_API_KEY=sk-... sbt "examples/runMain dspy4s.examples.tutorials.email_extraction.emailExtractionMain"

Notes

Integration with an external experiment-tracking server is out of scope. Per-field description hints are not carried on the Spec surface, so any field-level descriptions are dropped.

Full source: EmailExtraction.scala