FEATURE: smart date support for AI helper (#1044)

* FEATURE: smart date support for AI helper

This feature allows conversion of human typed in dates and times
to smart "Discourse" timezone friendly dates.

* fix specs and lint

* lint

* address feedback

* add specs
This commit is contained in:
Sam 2024-12-31 08:04:25 +11:00 committed by GitHub
parent f9f89adac5
commit 11d0f60f1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 532 additions and 8 deletions

View File

@ -233,6 +233,7 @@ en:
custom_prompt: "Custom Prompt"
explain: "Explain"
illustrate_post: "Illustrate Post"
replace_dates: "Smart dates"
painter:
attribution:
stable_diffusion_xl: "Image by Stable Diffusion XL"

View File

@ -1,5 +1,3 @@
# frozen_string_literal: true
# frozen_string_literal: true
CompletionPrompt.seed do |cp|
cp.id = -301
@ -217,3 +215,55 @@ CompletionPrompt.seed do |cp|
examples: [["<input>Hello my favourite colour is red</input>", "<output>en-GB</output>"]],
}
end
CompletionPrompt.seed do |cp|
cp.id = -310
cp.name = "replace_dates"
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
cp.temperature = 0
cp.stop_sequences = ["\n</output>"]
cp.messages = {
insts: <<~TEXT,
You are a date and time formatter for Discourse posts. Convert natural language time references into date placeholders.
Do not modify any markdown, code blocks, or existing date formats.
Here's the temporal context:
{{temporal_context}}
Available date placeholder formats:
- Simple day without time: {{date:1}} for tomorrow, {{date:7}} for a week from today
- Specific time: {{datetime:2pm+1}} for 2 PM tomorrow
- Time range: {{datetime:2pm+1:4pm+1}} for tomorrow 2 PM to 4 PM
You will find the text between <input></input> XML tags.
Return the text with dates converted between <output></output> XML tags.
TEXT
examples: [
[
"<input>The meeting is at 2pm tomorrow</input>",
"<output>The meeting is at {{datetime:2pm+1}}</output>",
],
["<input>Due in 3 days</input>", "<output>Due {{date:3}}</output>"],
[
"<input>Meeting next Tuesday at 2pm</input>",
"<output>Meeting {{next_week:tuesday-2pm}}</output>",
],
[
"<input>Meeting from 2pm to 4pm tomorrow</input>",
"<output>Meeting {{datetime:2pm+1:4pm+1}}</output>",
],
[
"<input>Meeting notes for tomorrow:
* Action items in `config.rb`
* Review PR #1234
* Deadline is 5pm
* Check [this link](https://example.com)</input>",
"<output>Meeting notes for {{date:1}}:
* Action items in `config.rb`
* Review PR #1234
* Deadline is {{datetime:5pm+1}}
* Check [this link](https://example.com)</output>",
],
],
}
end

View File

@ -60,7 +60,7 @@ module DiscourseAi
def custom_locale_instructions(user = nil, force_default_locale)
locale = SiteSetting.default_locale
locale = user.effective_locale if !force_default_locale
locale = user.effective_locale if !force_default_locale && user
locale_hash = LocaleSiteSetting.language_names[locale]
if locale != "en" && locale_hash
@ -71,7 +71,7 @@ module DiscourseAi
end
end
def localize_prompt!(prompt, user = nil, force_default_locale)
def localize_prompt!(prompt, user = nil, force_default_locale = false)
locale_instructions = custom_locale_instructions(user, force_default_locale)
if locale_instructions
prompt.messages[0][:content] = prompt.messages[0][:content] + locale_instructions
@ -89,6 +89,29 @@ module DiscourseAi
"#{locale_hash["name"]}",
)
end
if user && prompt.messages[0][:content].include?("{{temporal_context}}")
timezone = user.user_option.timezone || "UTC"
current_time = Time.now.in_time_zone(timezone)
temporal_context = {
utc_date_time: current_time.iso8601,
local_time: current_time.strftime("%H:%M"),
user: {
timezone: timezone,
weekday: current_time.strftime("%A"),
},
}
prompt.messages[0][:content] = prompt.messages[0][:content].gsub(
"{{temporal_context}}",
temporal_context.to_json,
)
prompt.messages.each do |message|
message[:content] = DateFormatter.process_date_placeholders(message[:content], user)
end
end
end
def generate_prompt(completion_prompt, input, user, force_default_locale = false, &block)
@ -206,6 +229,8 @@ module DiscourseAi
"question"
when "illustrate_post"
"images"
when "replace_dates"
"calendar-days"
else
nil
end
@ -233,6 +258,8 @@ module DiscourseAi
%w[post]
when "illustrate_post"
%w[composer]
when "replace_dates"
%w[composer]
else
%w[]
end

View File

@ -0,0 +1,144 @@
# frozen_string_literal: true
module DiscourseAi
module AiHelper
class DateFormatter
DAYS_OF_WEEK = {
"monday" => 1,
"tuesday" => 2,
"wednesday" => 3,
"thursday" => 4,
"friday" => 5,
"saturday" => 6,
"sunday" => 0,
}
class << self
def process_date_placeholders(text, user)
return text if !text.include?("{{")
timezone = user.user_option.timezone || "UTC"
reference_time = Time.now.in_time_zone(timezone)
text.gsub(
/\{\{(date_time_offset_minutes|date_offset_days|datetime|date|next_week):([^}]+)\}\}/,
) do |match|
type = $1
value = $2
case type
when "datetime"
if value.include?(":")
# Handle range like "2pm+1:3pm+2"
start_str, end_str = value.split(":")
format_datetime_range(
parse_time_with_offset(start_str, reference_time),
parse_time_with_offset(end_str, reference_time),
timezone,
)
else
# Handle single time like "2pm+1" or "10pm"
format_date_time(parse_time_with_offset(value, reference_time), timezone)
end
when "next_week"
if value.include?(":")
# Handle range like "tuesday-1pm:tuesday-3pm"
start_str, end_str = value.split(":")
start_time = parse_next_week(start_str, reference_time)
end_time = parse_next_week(end_str, reference_time)
format_datetime_range(start_time, end_time, timezone)
else
# Handle single time like "tuesday-1pm" or just "tuesday"
time = parse_next_week(value, reference_time)
value.include?("-") ? format_date_time(time, timezone) : format_date(time, timezone)
end
when "date"
format_date(reference_time + value.to_i.days, timezone)
when "date_time_offset_minutes"
if value.include?(":")
start_offset, end_offset = value.split(":").map(&:to_i)
format_datetime_range(
reference_time + start_offset.minutes,
reference_time + end_offset.minutes,
timezone,
)
else
format_date_time(reference_time + value.to_i.minutes, timezone)
end
when "date_offset_days"
if value.include?(":")
start_offset, end_offset = value.split(":").map(&:to_i)
format_date_range(
reference_time + start_offset.days,
reference_time + end_offset.days,
timezone,
)
else
format_date(reference_time + value.to_i.days, timezone)
end
end
end
end
private
def parse_next_week(str, reference_time)
if str.include?("-")
# Handle day with time like "tuesday-1pm"
day, time = str.split("-")
target_date = get_next_week_day(day.downcase, reference_time)
parse_time(time, target_date)
else
# Just the day
get_next_week_day(str.downcase, reference_time)
end
end
def get_next_week_day(day, reference_time)
raise ArgumentError unless DAYS_OF_WEEK.key?(day)
target_date = reference_time + 1.week
days_ahead = DAYS_OF_WEEK[day] - target_date.wday
days_ahead += 7 if days_ahead < 0
target_date + days_ahead.days
end
def parse_time_with_offset(time_str, reference_time)
if time_str.include?("+")
time_part, days = time_str.split("+")
parse_time(time_part, reference_time + days.to_i.days)
else
parse_time(time_str, reference_time)
end
end
def parse_time(time_str, reference_time)
hour = time_str.to_i
if time_str.downcase.include?("pm") && hour != 12
hour += 12
elsif time_str.downcase.include?("am") && hour == 12
hour = 0
end
reference_time.change(hour: hour, min: 0, sec: 0)
end
def format_date(time, timezone)
"[date=#{time.strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]"
end
def format_date_time(time, timezone)
"[date=#{time.strftime("%Y-%m-%d")} time=#{time.strftime("%H:%M:%S")} timezone=\"#{timezone}\"]"
end
def format_date_range(start_time, end_time, timezone)
"[date-range from=#{start_time.strftime("%Y-%m-%d")} to=#{end_time.strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]"
end
def format_datetime_range(start_time, end_time, timezone)
"[date-range from=#{start_time.strftime("%Y-%m-%dT%H:%M:%S")} to=#{end_time.strftime("%Y-%m-%dT%H:%M:%S")} timezone=\"#{timezone}\"]"
end
end
end
end
end

View File

@ -48,7 +48,7 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do
it "returns all available prompts" do
prompts = subject.available_prompts(user)
expect(prompts.length).to eq(7)
expect(prompts.length).to eq(8)
expect(prompts.map { |p| p[:name] }).to contain_exactly(
"translate",
"generate_titles",
@ -57,19 +57,21 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do
"custom_prompt",
"explain",
"detect_text_locale",
"replace_dates",
)
end
it "returns all prompts to be shown in the composer" do
prompts = subject.available_prompts(user)
filtered_prompts = prompts.select { |prompt| prompt[:location].include?("composer") }
expect(filtered_prompts.length).to eq(5)
expect(filtered_prompts.length).to eq(6)
expect(filtered_prompts.map { |p| p[:name] }).to contain_exactly(
"translate",
"generate_titles",
"proofread",
"markdown_table",
"custom_prompt",
"replace_dates",
)
end
@ -99,7 +101,7 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do
it "returns the illustrate_post prompt in the list of all prompts" do
prompts = subject.available_prompts(user)
expect(prompts.length).to eq(8)
expect(prompts.length).to eq(9)
expect(prompts.map { |p| p[:name] }).to contain_exactly(
"translate",
"generate_titles",
@ -109,6 +111,7 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do
"explain",
"illustrate_post",
"detect_text_locale",
"replace_dates",
)
end
end
@ -138,6 +141,45 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do
expect(prompt.messages[0][:content].strip).to eq("This is a English (US) test")
end
context "with temporal context" do
let(:prompt) do
CompletionPrompt.new(
messages: {
insts: "Current context: {{temporal_context}}",
},
).messages_with_input("test")
end
it "replaces temporal context with timezone information" do
timezone = "America/New_York"
user.user_option.update!(timezone: timezone)
freeze_time "2024-01-01 12:00:00"
subject.localize_prompt!(prompt, user)
content = prompt.messages[0][:content]
expect(content).to include(%("timezone":"America/New_York"))
end
it "uses UTC as default timezone when user timezone is not set" do
user.user_option.update!(timezone: nil)
freeze_time "2024-01-01 12:00:00" do
subject.localize_prompt!(prompt, user)
parsed_context = JSON.parse(prompt.messages[0][:content].match(/context: (.+)$/)[1])
expect(parsed_context["user"]["timezone"]).to eq("UTC")
end
end
it "does not replace temporal context when user is nil" do
prompt_content = prompt.messages[0][:content].dup
subject.localize_prompt!(prompt, nil)
expect(prompt.messages[0][:content]).to eq(prompt_content)
end
end
end
describe "#generate_and_send_prompt" do

View File

@ -0,0 +1,260 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiHelper::DateFormatter do
fab!(:user)
# Reference time is Tuesday Jan 16th, 2024 at 2:30 PM Sydney time
let(:sydney_reference) { DateTime.parse("2024-01-16 14:30:00 +11:00") }
describe ".process_date_placeholders" do
describe "with Sydney timezone" do
before do
user.user_option.update!(timezone: "Australia/Sydney")
freeze_time(sydney_reference)
end
describe "date_time_offset_minutes" do
it "handles minute offsets" do
expect(
described_class.process_date_placeholders(
"Meeting in {{date_time_offset_minutes:30}}",
user,
),
).to eq("Meeting in [date=2024-01-16 time=15:00:00 timezone=\"Australia/Sydney\"]")
expect(
described_class.process_date_placeholders(
"Meeting in {{date_time_offset_minutes:90}}",
user,
),
).to eq("Meeting in [date=2024-01-16 time=16:00:00 timezone=\"Australia/Sydney\"]")
end
it "handles minute ranges" do
expect(
described_class.process_date_placeholders(
"Meeting {{date_time_offset_minutes:60:180}}",
user,
),
).to eq(
"Meeting [date-range from=2024-01-16T15:30:00 to=2024-01-16T17:30:00 timezone=\"Australia/Sydney\"]",
)
end
end
describe "date_offset_days" do
it "handles day offsets" do
expect(
described_class.process_date_placeholders("Due {{date_offset_days:1}}", user),
).to eq("Due [date=2024-01-17 timezone=\"Australia/Sydney\"]")
expect(
described_class.process_date_placeholders("Due {{date_offset_days:7}}", user),
).to eq("Due [date=2024-01-23 timezone=\"Australia/Sydney\"]")
end
it "handles day ranges" do
expect(
described_class.process_date_placeholders("Event {{date_offset_days:1:3}}", user),
).to eq("Event [date-range from=2024-01-17 to=2024-01-19 timezone=\"Australia/Sydney\"]")
end
end
describe "datetime" do
it "handles absolute times today" do
expect(
described_class.process_date_placeholders("Meeting at {{datetime:2pm}}", user),
).to eq("Meeting at [date=2024-01-16 time=14:00:00 timezone=\"Australia/Sydney\"]")
expect(
described_class.process_date_placeholders("Meeting at {{datetime:10pm}}", user),
).to eq("Meeting at [date=2024-01-16 time=22:00:00 timezone=\"Australia/Sydney\"]")
end
it "handles absolute times with day offset" do
expect(
described_class.process_date_placeholders("Meeting {{datetime:2pm+1}}", user),
).to eq("Meeting [date=2024-01-17 time=14:00:00 timezone=\"Australia/Sydney\"]")
end
it "handles time ranges" do
expect(
described_class.process_date_placeholders("Meeting {{datetime:2pm:4pm}}", user),
).to eq(
"Meeting [date-range from=2024-01-16T14:00:00 to=2024-01-16T16:00:00 timezone=\"Australia/Sydney\"]",
)
end
it "handles time ranges with day offsets" do
expect(
described_class.process_date_placeholders("Meeting {{datetime:2pm+1:4pm+1}}", user),
).to eq(
"Meeting [date-range from=2024-01-17T14:00:00 to=2024-01-17T16:00:00 timezone=\"Australia/Sydney\"]",
)
end
it "handles 12-hour time edge cases" do
expect(described_class.process_date_placeholders("At {{datetime:12am}}", user)).to eq(
"At [date=2024-01-16 time=00:00:00 timezone=\"Australia/Sydney\"]",
)
expect(described_class.process_date_placeholders("At {{datetime:12pm}}", user)).to eq(
"At [date=2024-01-16 time=12:00:00 timezone=\"Australia/Sydney\"]",
)
end
end
describe "next_week" do
it "handles next week days" do
expect(
described_class.process_date_placeholders("Meeting {{next_week:tuesday}}", user),
).to eq("Meeting [date=2024-01-23 timezone=\"Australia/Sydney\"]")
end
it "handles next week with specific times" do
expect(
described_class.process_date_placeholders("Meeting {{next_week:tuesday-2pm}}", user),
).to eq("Meeting [date=2024-01-23 time=14:00:00 timezone=\"Australia/Sydney\"]")
end
it "handles next week time ranges" do
expect(
described_class.process_date_placeholders(
"Meeting {{next_week:tuesday-1pm:tuesday-3pm}}",
user,
),
).to eq(
"Meeting [date-range from=2024-01-23T13:00:00 to=2024-01-23T15:00:00 timezone=\"Australia/Sydney\"]",
)
end
end
end
describe "with Los Angeles timezone" do
before do
user.user_option.update!(timezone: "America/Los_Angeles")
# Still freeze at Sydney time, but formatter should work in LA time
freeze_time(sydney_reference)
end
it "handles current time conversions" do
# When it's 2:30 PM Tuesday in Sydney
# It's 7:30 PM Monday in LA
expect(
described_class.process_date_placeholders(
"Meeting {{date_time_offset_minutes:30}}",
user,
),
).to eq("Meeting [date=2024-01-15 time=20:00:00 timezone=\"America/Los_Angeles\"]")
end
it "handles absolute times" do
expect(described_class.process_date_placeholders("Meeting {{datetime:2pm}}", user)).to eq(
"Meeting [date=2024-01-15 time=14:00:00 timezone=\"America/Los_Angeles\"]",
)
end
describe "next_week" do
it "handles next week days in LA time" do
# From Monday night in LA (Tuesday in Sydney)
expect(
described_class.process_date_placeholders("Meeting {{next_week:tuesday}}", user),
).to eq("Meeting [date=2024-01-23 timezone=\"America/Los_Angeles\"]")
end
it "handles next week with specific times in LA" do
expect(
described_class.process_date_placeholders("Meeting {{next_week:tuesday-2pm}}", user),
).to eq("Meeting [date=2024-01-23 time=14:00:00 timezone=\"America/Los_Angeles\"]")
end
it "handles next week time ranges in LA" do
expect(
described_class.process_date_placeholders(
"Meeting {{next_week:tuesday-1pm:tuesday-3pm}}",
user,
),
).to eq(
"Meeting [date-range from=2024-01-23T13:00:00 to=2024-01-23T15:00:00 timezone=\"America/Los_Angeles\"]",
)
end
end
it "handles day transitions across timezones" do
expect(described_class.process_date_placeholders("Due {{date_offset_days:1}}", user)).to eq(
"Due [date=2024-01-16 timezone=\"America/Los_Angeles\"]",
)
end
end
describe "with UTC timezone" do
before do
user.user_option.update!(timezone: nil) # defaults to UTC
freeze_time(sydney_reference)
end
it "defaults to UTC for users without timezone" do
# When it's 2:30 PM in Sydney
# It's 3:30 AM in UTC
expect(
described_class.process_date_placeholders(
"Meeting {{date_time_offset_minutes:30}}",
user,
),
).to eq("Meeting [date=2024-01-16 time=04:00:00 timezone=\"UTC\"]")
end
describe "next_week" do
it "handles next week calculations in UTC" do
expect(
described_class.process_date_placeholders("Meeting {{next_week:tuesday-2pm}}", user),
).to eq("Meeting [date=2024-01-23 time=14:00:00 timezone=\"UTC\"]")
end
end
end
describe "error handling" do
before do
user.user_option.update!(timezone: "Australia/Sydney")
freeze_time(sydney_reference)
end
it "raises on invalid day name" do
expect {
described_class.process_date_placeholders("Meeting {{next_week:notaday}}", user)
}.to raise_error(ArgumentError)
end
it "raises on invalid time format" do
expect {
described_class.process_date_placeholders("Meeting {{datetime:25pm}}", user)
}.to raise_error(ArgumentError)
end
end
describe "mixed formats" do
before do
user.user_option.update!(timezone: "Australia/Sydney")
freeze_time(sydney_reference)
end
it "handles multiple different formats in the same text" do
input = [
"Meeting {{datetime:2pm+1}},",
"duration {{date_time_offset_minutes:60:180}},",
"repeats until {{date_offset_days:7}}",
"with sessions {{next_week:tuesday-1pm:tuesday-3pm}}",
].join(" ")
expected = [
"Meeting [date=2024-01-17 time=14:00:00 timezone=\"Australia/Sydney\"],",
"duration [date-range from=2024-01-16T15:30:00 to=2024-01-16T17:30:00 timezone=\"Australia/Sydney\"],",
"repeats until [date=2024-01-23 timezone=\"Australia/Sydney\"]",
"with sessions [date-range from=2024-01-23T13:00:00 to=2024-01-23T15:00:00 timezone=\"Australia/Sydney\"]",
].join(" ")
expect(described_class.process_date_placeholders(input, user)).to eq(expected)
end
end
end
end

View File

@ -19,7 +19,7 @@ describe Plugin::Instance do
it "returns the available prompts" do
expect(serializer.ai_helper_prompts).to be_present
expect(serializer.ai_helper_prompts.object.count).to eq(7)
expect(serializer.ai_helper_prompts.object.count).to eq(8)
end
end
end