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", "en-GB"]], } 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", + "The meeting is at {{datetime:2pm+1}}", + ], + ["Due in 3 days", "Due {{date:3}}"], + [ + "Meeting next Tuesday at 2pm", + "Meeting {{next_week:tuesday-2pm}}", + ], + [ + "Meeting from 2pm to 4pm tomorrow", + "Meeting {{datetime:2pm+1:4pm+1}}", + ], + [ + "Meeting notes for tomorrow: +* Action items in `config.rb` +* Review PR #1234 +* Deadline is 5pm +* Check [this link](https://example.com)", + "Meeting notes for {{date:1}}: +* Action items in `config.rb` +* Review PR #1234 +* Deadline is {{datetime:5pm+1}} +* 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