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:
- Add timezone support (parse “3pm EST” correctly)
- Handle recurring events (“every Monday”)
- Extract duration from phrases (“half hour meeting”)
- Parse date ranges (“December 15-17”)
- Support virtual meeting links
- 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