Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 11d0f60

Browse files
authored
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
1 parent f9f89ad commit 11d0f60

File tree

7 files changed

+532
-8
lines changed

7 files changed

+532
-8
lines changed

config/locales/server.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ en:
233233
custom_prompt: "Custom Prompt"
234234
explain: "Explain"
235235
illustrate_post: "Illustrate Post"
236+
replace_dates: "Smart dates"
236237
painter:
237238
attribution:
238239
stable_diffusion_xl: "Image by Stable Diffusion XL"

db/fixtures/ai_helper/603_completion_prompts.rb

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# frozen_string_literal: true
2-
31
# frozen_string_literal: true
42
CompletionPrompt.seed do |cp|
53
cp.id = -301
@@ -217,3 +215,55 @@
217215
examples: [["<input>Hello my favourite colour is red</input>", "<output>en-GB</output>"]],
218216
}
219217
end
218+
219+
CompletionPrompt.seed do |cp|
220+
cp.id = -310
221+
cp.name = "replace_dates"
222+
cp.prompt_type = CompletionPrompt.prompt_types[:diff]
223+
cp.temperature = 0
224+
cp.stop_sequences = ["\n</output>"]
225+
cp.messages = {
226+
insts: <<~TEXT,
227+
You are a date and time formatter for Discourse posts. Convert natural language time references into date placeholders.
228+
Do not modify any markdown, code blocks, or existing date formats.
229+
230+
Here's the temporal context:
231+
{{temporal_context}}
232+
233+
Available date placeholder formats:
234+
- Simple day without time: {{date:1}} for tomorrow, {{date:7}} for a week from today
235+
- Specific time: {{datetime:2pm+1}} for 2 PM tomorrow
236+
- Time range: {{datetime:2pm+1:4pm+1}} for tomorrow 2 PM to 4 PM
237+
238+
You will find the text between <input></input> XML tags.
239+
Return the text with dates converted between <output></output> XML tags.
240+
TEXT
241+
examples: [
242+
[
243+
"<input>The meeting is at 2pm tomorrow</input>",
244+
"<output>The meeting is at {{datetime:2pm+1}}</output>",
245+
],
246+
["<input>Due in 3 days</input>", "<output>Due {{date:3}}</output>"],
247+
[
248+
"<input>Meeting next Tuesday at 2pm</input>",
249+
"<output>Meeting {{next_week:tuesday-2pm}}</output>",
250+
],
251+
[
252+
"<input>Meeting from 2pm to 4pm tomorrow</input>",
253+
"<output>Meeting {{datetime:2pm+1:4pm+1}}</output>",
254+
],
255+
[
256+
"<input>Meeting notes for tomorrow:
257+
* Action items in `config.rb`
258+
* Review PR #1234
259+
* Deadline is 5pm
260+
* Check [this link](https://example.com)</input>",
261+
"<output>Meeting notes for {{date:1}}:
262+
* Action items in `config.rb`
263+
* Review PR #1234
264+
* Deadline is {{datetime:5pm+1}}
265+
* Check [this link](https://example.com)</output>",
266+
],
267+
],
268+
}
269+
end

lib/ai_helper/assistant.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def available_prompts(user)
6060

6161
def custom_locale_instructions(user = nil, force_default_locale)
6262
locale = SiteSetting.default_locale
63-
locale = user.effective_locale if !force_default_locale
63+
locale = user.effective_locale if !force_default_locale && user
6464
locale_hash = LocaleSiteSetting.language_names[locale]
6565

6666
if locale != "en" && locale_hash
@@ -71,7 +71,7 @@ def custom_locale_instructions(user = nil, force_default_locale)
7171
end
7272
end
7373

74-
def localize_prompt!(prompt, user = nil, force_default_locale)
74+
def localize_prompt!(prompt, user = nil, force_default_locale = false)
7575
locale_instructions = custom_locale_instructions(user, force_default_locale)
7676
if locale_instructions
7777
prompt.messages[0][:content] = prompt.messages[0][:content] + locale_instructions
@@ -89,6 +89,29 @@ def localize_prompt!(prompt, user = nil, force_default_locale)
8989
"#{locale_hash["name"]}",
9090
)
9191
end
92+
93+
if user && prompt.messages[0][:content].include?("{{temporal_context}}")
94+
timezone = user.user_option.timezone || "UTC"
95+
current_time = Time.now.in_time_zone(timezone)
96+
97+
temporal_context = {
98+
utc_date_time: current_time.iso8601,
99+
local_time: current_time.strftime("%H:%M"),
100+
user: {
101+
timezone: timezone,
102+
weekday: current_time.strftime("%A"),
103+
},
104+
}
105+
106+
prompt.messages[0][:content] = prompt.messages[0][:content].gsub(
107+
"{{temporal_context}}",
108+
temporal_context.to_json,
109+
)
110+
111+
prompt.messages.each do |message|
112+
message[:content] = DateFormatter.process_date_placeholders(message[:content], user)
113+
end
114+
end
92115
end
93116

94117
def generate_prompt(completion_prompt, input, user, force_default_locale = false, &block)
@@ -206,6 +229,8 @@ def icon_map(name)
206229
"question"
207230
when "illustrate_post"
208231
"images"
232+
when "replace_dates"
233+
"calendar-days"
209234
else
210235
nil
211236
end
@@ -233,6 +258,8 @@ def location_map(name)
233258
%w[post]
234259
when "illustrate_post"
235260
%w[composer]
261+
when "replace_dates"
262+
%w[composer]
236263
else
237264
%w[]
238265
end

lib/ai_helper/date_formatter.rb

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module AiHelper
5+
class DateFormatter
6+
DAYS_OF_WEEK = {
7+
"monday" => 1,
8+
"tuesday" => 2,
9+
"wednesday" => 3,
10+
"thursday" => 4,
11+
"friday" => 5,
12+
"saturday" => 6,
13+
"sunday" => 0,
14+
}
15+
16+
class << self
17+
def process_date_placeholders(text, user)
18+
return text if !text.include?("{{")
19+
20+
timezone = user.user_option.timezone || "UTC"
21+
reference_time = Time.now.in_time_zone(timezone)
22+
23+
text.gsub(
24+
/\{\{(date_time_offset_minutes|date_offset_days|datetime|date|next_week):([^}]+)\}\}/,
25+
) do |match|
26+
type = $1
27+
value = $2
28+
29+
case type
30+
when "datetime"
31+
if value.include?(":")
32+
# Handle range like "2pm+1:3pm+2"
33+
start_str, end_str = value.split(":")
34+
format_datetime_range(
35+
parse_time_with_offset(start_str, reference_time),
36+
parse_time_with_offset(end_str, reference_time),
37+
timezone,
38+
)
39+
else
40+
# Handle single time like "2pm+1" or "10pm"
41+
format_date_time(parse_time_with_offset(value, reference_time), timezone)
42+
end
43+
when "next_week"
44+
if value.include?(":")
45+
# Handle range like "tuesday-1pm:tuesday-3pm"
46+
start_str, end_str = value.split(":")
47+
start_time = parse_next_week(start_str, reference_time)
48+
end_time = parse_next_week(end_str, reference_time)
49+
format_datetime_range(start_time, end_time, timezone)
50+
else
51+
# Handle single time like "tuesday-1pm" or just "tuesday"
52+
time = parse_next_week(value, reference_time)
53+
value.include?("-") ? format_date_time(time, timezone) : format_date(time, timezone)
54+
end
55+
when "date"
56+
format_date(reference_time + value.to_i.days, timezone)
57+
when "date_time_offset_minutes"
58+
if value.include?(":")
59+
start_offset, end_offset = value.split(":").map(&:to_i)
60+
format_datetime_range(
61+
reference_time + start_offset.minutes,
62+
reference_time + end_offset.minutes,
63+
timezone,
64+
)
65+
else
66+
format_date_time(reference_time + value.to_i.minutes, timezone)
67+
end
68+
when "date_offset_days"
69+
if value.include?(":")
70+
start_offset, end_offset = value.split(":").map(&:to_i)
71+
format_date_range(
72+
reference_time + start_offset.days,
73+
reference_time + end_offset.days,
74+
timezone,
75+
)
76+
else
77+
format_date(reference_time + value.to_i.days, timezone)
78+
end
79+
end
80+
end
81+
end
82+
83+
private
84+
85+
def parse_next_week(str, reference_time)
86+
if str.include?("-")
87+
# Handle day with time like "tuesday-1pm"
88+
day, time = str.split("-")
89+
target_date = get_next_week_day(day.downcase, reference_time)
90+
parse_time(time, target_date)
91+
else
92+
# Just the day
93+
get_next_week_day(str.downcase, reference_time)
94+
end
95+
end
96+
97+
def get_next_week_day(day, reference_time)
98+
raise ArgumentError unless DAYS_OF_WEEK.key?(day)
99+
100+
target_date = reference_time + 1.week
101+
days_ahead = DAYS_OF_WEEK[day] - target_date.wday
102+
days_ahead += 7 if days_ahead < 0
103+
target_date + days_ahead.days
104+
end
105+
106+
def parse_time_with_offset(time_str, reference_time)
107+
if time_str.include?("+")
108+
time_part, days = time_str.split("+")
109+
parse_time(time_part, reference_time + days.to_i.days)
110+
else
111+
parse_time(time_str, reference_time)
112+
end
113+
end
114+
115+
def parse_time(time_str, reference_time)
116+
hour = time_str.to_i
117+
if time_str.downcase.include?("pm") && hour != 12
118+
hour += 12
119+
elsif time_str.downcase.include?("am") && hour == 12
120+
hour = 0
121+
end
122+
123+
reference_time.change(hour: hour, min: 0, sec: 0)
124+
end
125+
126+
def format_date(time, timezone)
127+
"[date=#{time.strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]"
128+
end
129+
130+
def format_date_time(time, timezone)
131+
"[date=#{time.strftime("%Y-%m-%d")} time=#{time.strftime("%H:%M:%S")} timezone=\"#{timezone}\"]"
132+
end
133+
134+
def format_date_range(start_time, end_time, timezone)
135+
"[date-range from=#{start_time.strftime("%Y-%m-%d")} to=#{end_time.strftime("%Y-%m-%d")} timezone=\"#{timezone}\"]"
136+
end
137+
138+
def format_datetime_range(start_time, end_time, timezone)
139+
"[date-range from=#{start_time.strftime("%Y-%m-%dT%H:%M:%S")} to=#{end_time.strftime("%Y-%m-%dT%H:%M:%S")} timezone=\"#{timezone}\"]"
140+
end
141+
end
142+
end
143+
end
144+
end

spec/lib/modules/ai_helper/assistant_spec.rb

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
it "returns all available prompts" do
4949
prompts = subject.available_prompts(user)
5050

51-
expect(prompts.length).to eq(7)
51+
expect(prompts.length).to eq(8)
5252
expect(prompts.map { |p| p[:name] }).to contain_exactly(
5353
"translate",
5454
"generate_titles",
@@ -57,19 +57,21 @@
5757
"custom_prompt",
5858
"explain",
5959
"detect_text_locale",
60+
"replace_dates",
6061
)
6162
end
6263

6364
it "returns all prompts to be shown in the composer" do
6465
prompts = subject.available_prompts(user)
6566
filtered_prompts = prompts.select { |prompt| prompt[:location].include?("composer") }
66-
expect(filtered_prompts.length).to eq(5)
67+
expect(filtered_prompts.length).to eq(6)
6768
expect(filtered_prompts.map { |p| p[:name] }).to contain_exactly(
6869
"translate",
6970
"generate_titles",
7071
"proofread",
7172
"markdown_table",
7273
"custom_prompt",
74+
"replace_dates",
7375
)
7476
end
7577

@@ -99,7 +101,7 @@
99101
it "returns the illustrate_post prompt in the list of all prompts" do
100102
prompts = subject.available_prompts(user)
101103

102-
expect(prompts.length).to eq(8)
104+
expect(prompts.length).to eq(9)
103105
expect(prompts.map { |p| p[:name] }).to contain_exactly(
104106
"translate",
105107
"generate_titles",
@@ -109,6 +111,7 @@
109111
"explain",
110112
"illustrate_post",
111113
"detect_text_locale",
114+
"replace_dates",
112115
)
113116
end
114117
end
@@ -138,6 +141,45 @@
138141

139142
expect(prompt.messages[0][:content].strip).to eq("This is a English (US) test")
140143
end
144+
145+
context "with temporal context" do
146+
let(:prompt) do
147+
CompletionPrompt.new(
148+
messages: {
149+
insts: "Current context: {{temporal_context}}",
150+
},
151+
).messages_with_input("test")
152+
end
153+
154+
it "replaces temporal context with timezone information" do
155+
timezone = "America/New_York"
156+
user.user_option.update!(timezone: timezone)
157+
freeze_time "2024-01-01 12:00:00"
158+
159+
subject.localize_prompt!(prompt, user)
160+
161+
content = prompt.messages[0][:content]
162+
163+
expect(content).to include(%("timezone":"America/New_York"))
164+
end
165+
166+
it "uses UTC as default timezone when user timezone is not set" do
167+
user.user_option.update!(timezone: nil)
168+
169+
freeze_time "2024-01-01 12:00:00" do
170+
subject.localize_prompt!(prompt, user)
171+
172+
parsed_context = JSON.parse(prompt.messages[0][:content].match(/context: (.+)$/)[1])
173+
expect(parsed_context["user"]["timezone"]).to eq("UTC")
174+
end
175+
end
176+
177+
it "does not replace temporal context when user is nil" do
178+
prompt_content = prompt.messages[0][:content].dup
179+
subject.localize_prompt!(prompt, nil)
180+
expect(prompt.messages[0][:content]).to eq(prompt_content)
181+
end
182+
end
141183
end
142184

143185
describe "#generate_and_send_prompt" do

0 commit comments

Comments
 (0)