Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 52 additions & 2 deletions db/fixtures/ai_helper/603_completion_prompts.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# frozen_string_literal: true

# frozen_string_literal: true
CompletionPrompt.seed do |cp|
cp.id = -301
Expand Down Expand Up @@ -217,3 +215,55 @@
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also add specs that checks to ensure it doesn't modify any of these?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is going to be pretty impossible, the llm is in charge of this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I meant, passing markdown containing code blocks for example, and checking if after the LLM call, the code blocks are still in-tact. But yes, I guess we can only mock calls in specs, so feel free to ignore then.

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
27 changes: 27 additions & 0 deletions lib/ai_helper/assistant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ def localize_prompt!(prompt, user = nil, force_default_locale)
"#{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)
Expand Down Expand Up @@ -206,6 +229,8 @@ def icon_map(name)
"question"
when "illustrate_post"
"images"
when "replace_dates"
"address-book"
else
nil
end
Expand Down Expand Up @@ -233,6 +258,8 @@ def location_map(name)
%w[post]
when "illustrate_post"
%w[composer]
when "replace_dates"
%w[composer]
else
%w[]
end
Expand Down
144 changes: 144 additions & 0 deletions lib/ai_helper/date_formatter.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions spec/lib/modules/ai_helper/assistant_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
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",
Expand All @@ -57,19 +57,21 @@
"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

Expand Down Expand Up @@ -99,7 +101,7 @@
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",
Expand All @@ -109,6 +111,7 @@
"explain",
"illustrate_post",
"detect_text_locale",
"replace_dates",
)
end
end
Expand Down
Loading
Loading