Powered by AppSignal & Oban Pro

Extract Event Details

livebooks/extract_event_details.livemd

Extract Event Details

Introduction

This notebook demonstrates extracting structured event information from casual, unstructured text. We’ll parse natural language descriptions and convert them into standardized event data.

Example Input: “Let’s postpone our math review to next Monday at 2pm. We can meet at 3 avenue des tanneurs.”

Structured Output: Title, location, ISO 8601 timestamp

Learning Objectives:

  • Extract structured data from casual text
  • Parse relative time references (“next Monday”)
  • Convert to standardized formats (ISO 8601)
  • Handle location extraction
  • Build calendar integration schemas

Prerequisites:

  • Basic Elixir knowledge
  • Familiarity with ExOutlines
  • OpenAI API key

Setup

# Install dependencies
Mix.install([
  {:ex_outlines, "~> 0.2.0"},
  {:kino, "~> 0.12"}
])
# Imports and aliases
alias ExOutlines.{Spec.Schema, Backend.HTTP}

# Configuration
api_key = System.fetch_env!("LB_OPENAI_API_KEY")
model = "gpt-4o-mini"

:ok

Event Extraction Schema

Define a schema for structured event information.

# Schema for event details
event_schema =
  Schema.new(%{
    title: %{
      type: :string,
      required: true,
      min_length: 3,
      max_length: 100,
      description: "Event title or subject"
    },
    location: %{
      type: {:union, [%{type: :string, max_length: 200}, %{type: :null}]},
      required: false,
      description: "Physical or virtual location"
    },
    start_time: %{
      type: :string,
      required: true,
      pattern: ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
      description: "Start time in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)"
    },
    end_time: %{
      type: {:union, [
        %{type: :string, pattern: ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/},
        %{type: :null}
      ]},
      required: false,
      description: "End time in ISO 8601 format (optional)"
    },
    duration_minutes: %{
      type: {:union, [%{type: :integer, min: 15, max: 480}, %{type: :null}]},
      required: false,
      description: "Event duration in minutes (15 minutes to 8 hours)"
    },
    attendees: %{
      type: {:array, %{type: :string, max_length: 100}},
      required: false,
      unique_items: true,
      max_items: 20,
      description: "List of attendee names or emails"
    },
    notes: %{
      type: {:union, [%{type: :string, max_length: 500}, %{type: :null}]},
      required: false,
      description: "Additional notes or context"
    }
  })

IO.puts("Event extraction schema defined")
:ok

Example 1: Postponed Meeting

Extract event details from a rescheduling message.

# Context: Today is Saturday, November 16, 2024, at 10:55 AM
context = %{
  current_date: ~D[2024-11-16],
  current_time: ~T[10:55:00],
  current_datetime: ~U[2024-11-16 10:55:00Z]
}

message_1 = """
Let's postpone our math review to next Monday at 2pm.
We can meet at 3 avenue des tanneurs.
"""

# Build prompt with context
prompt_1 = """
Today is #{Date.day_of_week(context.current_date) |> day_name()}, #{Calendar.strftime(context.current_date, "%B %d, %Y")}
Current time: #{Time.to_string(context.current_time)}

Message:
#{message_1}

Extract structured event information. Convert relative times to absolute ISO 8601 format.
"""

IO.puts("=== Example 1: Postponed Meeting ===")
IO.puts("\nContext:")
IO.puts("  Date: #{context.current_date}")
IO.puts("  Time: #{context.current_time}")
IO.puts("\nMessage:")
IO.puts(message_1)

# In production:
# {:ok, event} = ExOutlines.generate(event_schema,
#   backend: HTTP,
#   backend_opts: [
#     api_key: api_key,
#     model: model,
#     messages: [
#       %{role: "system", content: "Extract event details from messages."},
#       %{role: "user", content: prompt_1}
#     ]
#   ]
# )

expected_event_1 = %{
  "title" => "Math Review",
  "location" => "3 avenue des tanneurs",
  "start_time" => "2024-11-18T14:00:00Z",  # Next Monday at 2pm
  "end_time" => nil,
  "duration_minutes" => 60,  # Assumed default
  "attendees" => [],
  "notes" => "Postponed from original schedule"
}

IO.puts("\n=== Extracted Event ===")
IO.inspect(expected_event_1, pretty: true)

# Validate
case Spec.validate(event_schema, expected_event_1) do
  {:ok, validated} ->
    IO.puts("\n[SUCCESS] Valid event extraction")
    IO.puts("\nFormatted:")
    IO.puts("  Title: #{validated.title}")
    IO.puts("  When: #{validated.start_time}")
    IO.puts("  Where: #{validated.location}")
    validated

  {:error, diagnostics} ->
    IO.puts("\n[FAILED] Validation errors:")
    Enum.each(diagnostics.errors, fn error ->
      IO.puts("  #{error.message}")
    end)
    nil
end

# Helper function for day names
defp day_name(1), do: "Monday"
defp day_name(2), do: "Tuesday"
defp day_name(3), do: "Wednesday"
defp day_name(4), do: "Thursday"
defp day_name(5), do: "Friday"
defp day_name(6), do: "Saturday"
defp day_name(7), do: "Sunday"

Example 2: Team Meeting

Extract from a more complex message with multiple details.

message_2 = """
Team sync tomorrow at 10:30am in Conference Room B.
We'll discuss Q4 planning, budget review, and the new project launch.
Expected attendees: Sarah, John, Maria, and Alex.
Should take about 90 minutes.
"""

expected_event_2 = %{
  "title" => "Team Sync - Q4 Planning",
  "location" => "Conference Room B",
  "start_time" => "2024-11-17T10:30:00Z",  # Tomorrow at 10:30am
  "end_time" => "2024-11-17T12:00:00Z",  # 90 minutes later
  "duration_minutes" => 90,
  "attendees" => ["Sarah", "John", "Maria", "Alex"],
  "notes" => "Topics: Q4 planning, budget review, new project launch"
}

IO.puts("\n\n=== Example 2: Team Meeting ===")
IO.puts("\nMessage:")
IO.puts(message_2)
IO.puts("\n=== Extracted Event ===")
IO.inspect(expected_event_2, pretty: true)

case Spec.validate(event_schema, expected_event_2) do
  {:ok, validated} ->
    IO.puts("\n[SUCCESS] Valid event extraction")
    IO.puts("\nFormatted:")
    IO.puts("  #{validated.title}")
    IO.puts("  #{validated.start_time} - #{validated.end_time}")
    IO.puts("  Duration: #{validated.duration_minutes} minutes")
    IO.puts("  Location: #{validated.location}")
    IO.puts("  Attendees: #{Enum.join(validated.attendees, ", ")}")
    validated

  {:error, diagnostics} ->
    IO.puts("\n[FAILED] Validation errors:")
    Enum.each(diagnostics.errors, fn error ->
      IO.puts("  #{error.message}")
    end)
    nil
end

Time Parsing Helpers

Functions to convert relative time references to absolute timestamps.

defmodule TimeParser do
  @moduledoc """
  Parse relative time references to absolute ISO 8601 timestamps.
  """

  @doc """
  Calculate absolute date from relative references.
  """
  def parse_relative_day(reference, base_date) do
    case String.downcase(reference) do
      "today" -> base_date
      "tomorrow" -> Date.add(base_date, 1)
      "next week" -> Date.add(base_date, 7)
      "next monday" -> next_day_of_week(base_date, 1)
      "next tuesday" -> next_day_of_week(base_date, 2)
      "next wednesday" -> next_day_of_week(base_date, 3)
      "next thursday" -> next_day_of_week(base_date, 4)
      "next friday" -> next_day_of_week(base_date, 5)
      "next saturday" -> next_day_of_week(base_date, 6)
      "next sunday" -> next_day_of_week(base_date, 7)
      _ -> base_date
    end
  end

  defp next_day_of_week(base_date, target_day) do
    current_day = Date.day_of_week(base_date)
    days_ahead = rem(target_day - current_day + 7, 7)
    days_ahead = if days_ahead == 0, do: 7, else: days_ahead
    Date.add(base_date, days_ahead)
  end

  @doc """
  Parse time string to Time struct.
  """
  def parse_time(time_str) do
    time_str = String.downcase(time_str)

    cond do
      String.contains?(time_str, "pm") ->
        parse_12h(time_str, :pm)

      String.contains?(time_str, "am") ->
        parse_12h(time_str, :am)

      true ->
        parse_24h(time_str)
    end
  end

  defp parse_12h(time_str, period) do
    # Extract hour and minutes
    case Regex.run(~r/(\d{1,2}):?(\d{2})?/, time_str) do
      [_, hour_str] ->
        hour = String.to_integer(hour_str)
        hour = adjust_for_period(hour, period)
        {:ok, Time.new!(hour, 0, 0)}

      [_, hour_str, min_str] ->
        hour = String.to_integer(hour_str)
        hour = adjust_for_period(hour, period)
        min = String.to_integer(min_str)
        {:ok, Time.new!(hour, min, 0)}

      _ ->
        {:error, :invalid_time}
    end
  end

  defp adjust_for_period(hour, :am) when hour == 12, do: 0
  defp adjust_for_period(hour, :am), do: hour
  defp adjust_for_period(hour, :pm) when hour == 12, do: 12
  defp adjust_for_period(hour, :pm), do: hour + 12

  defp parse_24h(time_str) do
    case Regex.run(~r/(\d{1,2}):(\d{2})/, time_str) do
      [_, hour_str, min_str] ->
        {:ok, Time.new!(String.to_integer(hour_str), String.to_integer(min_str), 0)}

      _ ->
        {:error, :invalid_time}
    end
  end

  @doc """
  Combine date and time into ISO 8601 string.
  """
  def to_iso8601(date, time) do
    datetime = DateTime.new!(date, time, "Etc/UTC")
    DateTime.to_iso8601(datetime)
  end
end

# Test time parsing
IO.puts("\n\n=== Time Parsing Examples ===")

base_date = ~D[2024-11-16]  # Saturday

examples = [
  {"tomorrow", "10:30am"},
  {"next Monday", "2pm"},
  {"next Friday", "14:00"},
  {"today", "9:15am"}
]

Enum.each(examples, fn {day_ref, time_ref} ->
  date = TimeParser.parse_relative_day(day_ref, base_date)
  {:ok, time} = TimeParser.parse_time(time_ref)
  iso8601 = TimeParser.to_iso8601(date, time)

  IO.puts("\"#{day_ref} at #{time_ref}\" -> #{iso8601}")
end)

Calendar Integration

Generate iCalendar (ICS) format for calendar apps.

defmodule ICalendarGenerator do
  @moduledoc """
  Generate iCalendar format from event data.
  """

  def to_ics(event) do
    """
    BEGIN:VCALENDAR
    VERSION:2.0
    PRODID:-//ExOutlines//Event Extractor//EN
    BEGIN:VEVENT
    UID:#{generate_uid()}
    DTSTART:#{format_ics_datetime(event.start_time)}
    #{if event.end_time, do: "DTEND:#{format_ics_datetime(event.end_time)}\n", else: ""}SUMMARY:#{event.title}
    #{if event.location, do: "LOCATION:#{event.location}\n", else: ""}#{if event.notes, do: "DESCRIPTION:#{event.notes}\n", else: ""}#{if event.attendees && length(event.attendees) > 0, do: format_attendees(event.attendees), else: ""}END:VEVENT
    END:VCALENDAR
    """
  end

  defp generate_uid do
    :crypto.strong_rand_bytes(16)
    |> Base.encode16(case: :lower)
    |> Kernel.<>("@exoutlines.local")
  end

  defp format_ics_datetime(iso8601_str) do
    # Convert ISO 8601 to iCalendar format (YYYYMMDDTHHMMSSZ)
    String.replace(iso8601_str, ~r/[-:]/, "")
  end

  defp format_attendees(attendees) do
    attendees
    |> Enum.map(fn attendee ->
      "ATTENDEE:mailto:#{String.downcase(String.replace(attendee, " ", "."))}@example.com\n"
    end)
    |> Enum.join()
  end
end

# Generate ICS for extracted event
if expected_event_1 do
  ics_content = ICalendarGenerator.to_ics(expected_event_1)

  IO.puts("\n\n=== iCalendar Format ===")
  IO.puts(ics_content)
  IO.puts("\nThis can be imported into Google Calendar, Outlook, Apple Calendar, etc.")
end

Batch Event Extraction

Extract multiple events from a longer message.

defmodule BatchEventExtractor do
  def extract_multiple(text, api_key, model) do
    # Schema for multiple events
    multi_event_schema = Schema.new(%{
      events: %{
        type: {:array, %{type: {:object, event_schema}}},
        required: true,
        min_items: 1,
        max_items: 10,
        description: "List of extracted events"
      }
    })

    # In production:
    # ExOutlines.generate(multi_event_schema,
    #   backend: HTTP,
    #   backend_opts: [
    #     api_key: api_key,
    #     model: model,
    #     messages: [
    #       %{role: "system", content: "Extract all events from the message."},
    #       %{role: "user", content: text}
    #     ]
    #   ]
    # )

    # Simulated
    {:ok, [expected_event_1, expected_event_2]}
  end
end

# Example: Extract multiple events
complex_message = """
Quick update on this week's schedule:

Monday at 9am - Weekly standup in the main conference room with the whole team.

Tuesday at 2:30pm - Client presentation at their office (123 Main St).
This will be about 2 hours.

Friday at 4pm - Happy hour at Joe's Bar! Everyone's invited.
"""

IO.puts("\n\n=== Batch Extraction ===")
IO.puts("\nMessage:")
IO.puts(complex_message)
IO.puts("\n(Would extract 3 separate events from this message)")

Production Implementation

defmodule ProductionEventExtractor do
  @moduledoc """
  Production-ready event extraction system.
  """

  def extract(message, context, opts \\ []) do
    api_key = Keyword.fetch!(opts, :api_key)
    model = Keyword.get(opts, :model, "gpt-4o-mini")

    # Build prompt with context
    prompt = build_prompt(message, context)

    # Extract event
    case ExOutlines.generate(event_schema(),
      backend: HTTP,
      backend_opts: [
        api_key: api_key,
        model: model,
        messages: [
          %{role: "system", content: system_prompt()},
          %{role: "user", content: prompt}
        ]
      ]
    ) do
      {:ok, event} ->
        # Post-process and validate
        case post_process(event, context) do
          {:ok, processed_event} ->
            {:ok, processed_event}

          {:error, reason} ->
            {:error, reason}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp system_prompt do
    """
    You are an event extraction system. Extract structured event information from messages.

    Requirements:
    - Convert all times to ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)
    - Parse relative times ("tomorrow", "next Monday") correctly
    - Extract all attendees mentioned
    - Infer reasonable defaults for missing information (e.g., 60-minute duration)
    - Include context in notes field when relevant
    """
  end

  defp build_prompt(message, context) do
    """
    Current context:
    Date: #{context.current_date}
    Time: #{context.current_time}
    Day of week: #{Date.day_of_week(context.current_date)}

    Message:
    #{message}

    Extract the event details.
    """
  end

  defp post_process(event, context) do
    # Validate extracted time is in the future
    case DateTime.from_iso8601(event.start_time) do
      {:ok, start_dt, _} ->
        if DateTime.compare(start_dt, context.current_datetime) == :gt do
          {:ok, event}
        else
          {:error, "Extracted time is in the past"}
        end

      {:error, _} ->
        {:error, "Invalid datetime format"}
    end
  end

  defp event_schema do
    # Return the event schema
  end
end

Key Takeaways

Event Extraction Pattern:

  • Parse natural language to structured data
  • Handle relative time references
  • Convert to standardized formats
  • Extract all relevant metadata

Schema Design:

  • Use ISO 8601 for timestamps (interoperable)
  • Make location and end_time optional
  • Include notes field for context
  • Support multiple attendees

Production Considerations:

  • Always include current date/time context
  • Validate extracted times are reasonable
  • Handle timezone conversions
  • Support batch extraction
  • Generate calendar-compatible formats

Common Challenges:

  • Ambiguous time references (“this Friday” - which one?)
  • Missing timezone information
  • Informal location descriptions
  • Implicit attendees
  • Duration estimation

Real-World Applications

Calendar Integration:

  • Email-to-calendar parsers
  • SMS event extraction
  • Meeting note processors
  • Schedule coordinators

Task Management:

  • Project deadline extraction
  • Milestone tracking
  • Reminder systems
  • Team coordination

Customer Service:

  • Appointment scheduling
  • Booking systems
  • Follow-up tracking
  • Service requests

Challenges

Try these exercises:

  1. Add timezone support (parse “3pm EST” correctly)
  2. Handle recurring events (“every Monday”)
  3. Extract duration from phrases (“half hour meeting”)
  4. Parse date ranges (“December 15-17”)
  5. Support virtual meeting links
  6. Handle conflicts and double-bookings

Next Steps

  • Try the Named Entity Extraction notebook for general extraction patterns
  • Explore the Chain of Thought notebook for complex parsing
  • Read the Schema Patterns guide for format validation
  • Check the Phoenix Integration guide for web applications

Further Reading