diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index dd5c43ca..d7114296 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -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"
diff --git a/db/fixtures/ai_helper/603_completion_prompts.rb b/db/fixtures/ai_helper/603_completion_prompts.rb
index 8b3eebeb..bab854b1 100644
--- a/db/fixtures/ai_helper/603_completion_prompts.rb
+++ b/db/fixtures/ai_helper/603_completion_prompts.rb
@@ -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: [["Hello my favourite colour is red", ""]],
}
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"]
+ 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 XML tags.
+ Return the text with dates converted between XML tags.
+ TEXT
+ examples: [
+ [
+ "The meeting is at 2pm tomorrow",
+ "",
+ ],
+ ["Due in 3 days", ""],
+ [
+ "Meeting next Tuesday at 2pm",
+ "",
+ ],
+ [
+ "Meeting from 2pm to 4pm tomorrow",
+ "",
+ ],
+ [
+ "Meeting notes for tomorrow:
+* Action items in `config.rb`
+* Review PR #1234
+* Deadline is 5pm
+* Check [this link](https://example.com)",
+ "",
+ ],
+ ],
+ }
+end
diff --git a/lib/ai_helper/assistant.rb b/lib/ai_helper/assistant.rb
index 3b7fc94a..7333db35 100644
--- a/lib/ai_helper/assistant.rb
+++ b/lib/ai_helper/assistant.rb
@@ -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
diff --git a/lib/ai_helper/date_formatter.rb b/lib/ai_helper/date_formatter.rb
new file mode 100644
index 00000000..382fb4d4
--- /dev/null
+++ b/lib/ai_helper/date_formatter.rb
@@ -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
diff --git a/spec/lib/modules/ai_helper/assistant_spec.rb b/spec/lib/modules/ai_helper/assistant_spec.rb
index bb83c108..125df1f9 100644
--- a/spec/lib/modules/ai_helper/assistant_spec.rb
+++ b/spec/lib/modules/ai_helper/assistant_spec.rb
@@ -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
diff --git a/spec/lib/modules/ai_helper/date_formatter_spec.rb b/spec/lib/modules/ai_helper/date_formatter_spec.rb
new file mode 100644
index 00000000..602cd590
--- /dev/null
+++ b/spec/lib/modules/ai_helper/date_formatter_spec.rb
@@ -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
diff --git a/spec/plugin_spec.rb b/spec/plugin_spec.rb
index cffd641c..3ddc398f 100644
--- a/spec/plugin_spec.rb
+++ b/spec/plugin_spec.rb
@@ -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