mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 08:44:02 +00:00
fix(bot): refine reminder scheduling and topic routing
This commit is contained in:
@@ -11,6 +11,8 @@ describe('parseAdHocNotificationSchedule', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 15,
|
||||
resolvedMinute: 30,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'exact',
|
||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
})
|
||||
@@ -26,6 +28,8 @@ describe('parseAdHocNotificationSchedule', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 12,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'calendar',
|
||||
resolutionMode: 'date_only',
|
||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
})
|
||||
@@ -41,6 +45,8 @@ describe('parseAdHocNotificationSchedule', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 9,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'fuzzy_window',
|
||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
})
|
||||
@@ -56,6 +62,8 @@ describe('parseAdHocNotificationSchedule', () => {
|
||||
resolvedLocalDate: null,
|
||||
resolvedHour: null,
|
||||
resolvedMinute: null,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: null,
|
||||
resolutionMode: 'ambiguous',
|
||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
})
|
||||
@@ -69,10 +77,45 @@ describe('parseAdHocNotificationSchedule', () => {
|
||||
resolvedLocalDate: '2026-03-23',
|
||||
resolvedHour: 10,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'calendar',
|
||||
resolutionMode: 'exact',
|
||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
})
|
||||
|
||||
expect(parsed.kind).toBe('invalid_past')
|
||||
})
|
||||
|
||||
test('supports relative offsets like in 30 minutes', () => {
|
||||
const parsed = parseAdHocNotificationSchedule({
|
||||
timezone: 'Asia/Tbilisi',
|
||||
resolvedLocalDate: null,
|
||||
resolvedHour: null,
|
||||
resolvedMinute: null,
|
||||
relativeOffsetMinutes: 30,
|
||||
dateReferenceMode: null,
|
||||
resolutionMode: 'exact',
|
||||
now: Temporal.Instant.from('2026-03-24T08:00:00Z')
|
||||
})
|
||||
|
||||
expect(parsed.kind).toBe('parsed')
|
||||
expect(parsed.timePrecision).toBe('exact')
|
||||
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T08:30:00Z')
|
||||
})
|
||||
|
||||
test('reinterprets pre-dawn relative tomorrow as the upcoming same-calendar day', () => {
|
||||
const parsed = parseAdHocNotificationSchedule({
|
||||
timezone: 'Asia/Tbilisi',
|
||||
resolvedLocalDate: '2026-03-25',
|
||||
resolvedHour: 9,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'fuzzy_window',
|
||||
now: Temporal.Instant.from('2026-03-24T00:39:00Z')
|
||||
})
|
||||
|
||||
expect(parsed.kind).toBe('parsed')
|
||||
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T05:00:00Z')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,12 +27,14 @@ export function parseAdHocNotificationSchedule(input: {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes?: number | null
|
||||
dateReferenceMode?: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
now?: Instant
|
||||
}): ParsedAdHocNotificationSchedule {
|
||||
const effectiveNow = input.now ?? nowInstant()
|
||||
const timePrecision = precisionFromResolutionMode(input.resolutionMode)
|
||||
if (
|
||||
!input.resolvedLocalDate ||
|
||||
input.resolutionMode === null ||
|
||||
input.resolutionMode === 'ambiguous' ||
|
||||
timePrecision === null
|
||||
@@ -44,6 +46,31 @@ export function parseAdHocNotificationSchedule(input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.relativeOffsetMinutes !== null && input.relativeOffsetMinutes !== undefined) {
|
||||
const scheduled = effectiveNow.add({ minutes: input.relativeOffsetMinutes })
|
||||
if (scheduled.epochMilliseconds <= effectiveNow.epochMilliseconds) {
|
||||
return {
|
||||
kind: 'invalid_past',
|
||||
scheduledFor: null,
|
||||
timePrecision: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'parsed',
|
||||
scheduledFor: scheduled,
|
||||
timePrecision
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.resolvedLocalDate) {
|
||||
return {
|
||||
kind: 'missing_schedule',
|
||||
scheduledFor: null,
|
||||
timePrecision: null
|
||||
}
|
||||
}
|
||||
|
||||
const hour =
|
||||
input.resolutionMode === 'date_only' ? (input.resolvedHour ?? 12) : input.resolvedHour
|
||||
const minute =
|
||||
@@ -58,7 +85,17 @@ export function parseAdHocNotificationSchedule(input: {
|
||||
}
|
||||
|
||||
try {
|
||||
const date = Temporal.PlainDate.from(input.resolvedLocalDate)
|
||||
const nowZdt = effectiveNow.toZonedDateTimeISO(input.timezone)
|
||||
let date = Temporal.PlainDate.from(input.resolvedLocalDate)
|
||||
|
||||
if (
|
||||
input.dateReferenceMode === 'relative' &&
|
||||
nowZdt.hour <= 4 &&
|
||||
Temporal.PlainDate.compare(date, nowZdt.toPlainDate().add({ days: 1 })) === 0
|
||||
) {
|
||||
date = nowZdt.toPlainDate()
|
||||
}
|
||||
|
||||
const scheduled = Temporal.ZonedDateTime.from({
|
||||
timeZone: input.timezone,
|
||||
year: date.year,
|
||||
@@ -70,7 +107,6 @@ export function parseAdHocNotificationSchedule(input: {
|
||||
millisecond: 0
|
||||
}).toInstant()
|
||||
|
||||
const effectiveNow = input.now ?? nowInstant()
|
||||
if (scheduled.epochMilliseconds <= effectiveNow.epochMilliseconds) {
|
||||
return {
|
||||
kind: 'invalid_past',
|
||||
|
||||
@@ -16,34 +16,38 @@ import { formatReminderWhen, registerAdHocNotifications } from './ad-hoc-notific
|
||||
import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter'
|
||||
|
||||
function createPromptRepository(): TelegramPendingActionRepository {
|
||||
let pending: TelegramPendingActionRecord | null = null
|
||||
const pending = new Map<string, TelegramPendingActionRecord>()
|
||||
|
||||
return {
|
||||
async upsertPendingAction(input) {
|
||||
pending = input
|
||||
pending.set(`${input.telegramChatId}:${input.telegramUserId}`, input)
|
||||
return input
|
||||
},
|
||||
async getPendingAction() {
|
||||
return pending
|
||||
async getPendingAction(telegramChatId, telegramUserId) {
|
||||
return pending.get(`${telegramChatId}:${telegramUserId}`) ?? null
|
||||
},
|
||||
async clearPendingAction() {
|
||||
pending = null
|
||||
async clearPendingAction(telegramChatId, telegramUserId) {
|
||||
pending.delete(`${telegramChatId}:${telegramUserId}`)
|
||||
},
|
||||
async clearPendingActionsForChat(telegramChatId, action) {
|
||||
if (!pending || pending.telegramChatId !== telegramChatId) {
|
||||
return
|
||||
for (const [key, value] of pending.entries()) {
|
||||
if (value.telegramChatId !== telegramChatId) {
|
||||
continue
|
||||
}
|
||||
if (action && value.action !== action) {
|
||||
continue
|
||||
}
|
||||
pending.delete(key)
|
||||
}
|
||||
|
||||
if (action && pending.action !== action) {
|
||||
return
|
||||
}
|
||||
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reminderMessageUpdate(text: string, threadId = 777) {
|
||||
function reminderMessageUpdate(
|
||||
text: string,
|
||||
threadId = 777,
|
||||
from: { id: number; firstName: string } = { id: 10002, firstName: 'Dima' }
|
||||
) {
|
||||
return {
|
||||
update_id: 4001,
|
||||
message: {
|
||||
@@ -56,9 +60,9 @@ function reminderMessageUpdate(text: string, threadId = 777) {
|
||||
type: 'supergroup'
|
||||
},
|
||||
from: {
|
||||
id: 10002,
|
||||
id: from.id,
|
||||
is_bot: false,
|
||||
first_name: 'Dima'
|
||||
first_name: from.firstName
|
||||
},
|
||||
text
|
||||
}
|
||||
@@ -110,6 +114,7 @@ function member(
|
||||
function createHouseholdRepository() {
|
||||
const members = [
|
||||
member({ id: 'dima', telegramUserId: '10002', displayName: 'Дима' }),
|
||||
member({ id: 'stas', telegramUserId: '10003', displayName: 'Стас' }),
|
||||
member({ id: 'georgiy', displayName: 'Георгий' })
|
||||
]
|
||||
const settings: HouseholdBillingSettingsRecord = {
|
||||
@@ -238,6 +243,8 @@ describe('registerAdHocNotifications', () => {
|
||||
resolvedLocalDate: tomorrow,
|
||||
resolvedHour: 9,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'fuzzy_window',
|
||||
clarificationQuestion: null,
|
||||
confidence: 90,
|
||||
@@ -250,6 +257,8 @@ describe('registerAdHocNotifications', () => {
|
||||
resolvedLocalDate: tomorrow,
|
||||
resolvedHour: 9,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'fuzzy_window',
|
||||
clarificationQuestion: null,
|
||||
confidence: 90,
|
||||
@@ -267,6 +276,8 @@ describe('registerAdHocNotifications', () => {
|
||||
resolvedLocalDate: null,
|
||||
resolvedHour: null,
|
||||
resolvedMinute: null,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: null,
|
||||
resolutionMode: null,
|
||||
deliveryMode: null,
|
||||
dmRecipientMemberIds: null,
|
||||
@@ -284,6 +295,8 @@ describe('registerAdHocNotifications', () => {
|
||||
resolvedLocalDate: tomorrow,
|
||||
resolvedHour: 10,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'exact',
|
||||
deliveryMode: null,
|
||||
dmRecipientMemberIds: null,
|
||||
@@ -573,6 +586,8 @@ describe('registerAdHocNotifications', () => {
|
||||
resolvedLocalDate: tomorrow,
|
||||
resolvedHour: 9,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'fuzzy_window',
|
||||
clarificationQuestion: null,
|
||||
confidence: 90,
|
||||
@@ -610,6 +625,148 @@ describe('registerAdHocNotifications', () => {
|
||||
expect(expandedPayload.reply_markup?.inline_keyboard[0]?.[2]?.text).toBe('Скрыть')
|
||||
expect(expandedPayload.reply_markup?.inline_keyboard[1]?.[0]?.text).toContain('В топик')
|
||||
})
|
||||
test('supports relative duration reminders like in 30 minutes', async () => {
|
||||
const bot = createTelegramBot('000000:test-token')
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
const scheduledRequests: Array<{ scheduledFor: string }> = []
|
||||
const now = Temporal.Instant.from('2026-03-24T08:00:00Z')
|
||||
const promptRepository = createPromptRepository()
|
||||
|
||||
bot.botInfo = {
|
||||
id: 999000,
|
||||
is_bot: true,
|
||||
first_name: 'Household Test Bot',
|
||||
username: 'household_test_bot',
|
||||
can_join_groups: true,
|
||||
can_read_all_group_messages: false,
|
||||
supports_inline_queries: false,
|
||||
can_connect_to_business: false,
|
||||
has_main_web_app: false,
|
||||
has_topics_enabled: true,
|
||||
allows_users_to_create_topics: false
|
||||
}
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
message_id: calls.length,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: -10012345,
|
||||
type: 'supergroup'
|
||||
},
|
||||
text: 'ok'
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
const originalNow = Temporal.Now.instant
|
||||
Temporal.Now.instant = () => now
|
||||
|
||||
try {
|
||||
registerAdHocNotifications({
|
||||
bot,
|
||||
householdConfigurationRepository: createHouseholdRepository() as never,
|
||||
promptRepository,
|
||||
notificationService: {
|
||||
async scheduleNotification(input) {
|
||||
scheduledRequests.push({
|
||||
scheduledFor: input.scheduledFor.toString()
|
||||
})
|
||||
return {
|
||||
status: 'scheduled',
|
||||
notification: {
|
||||
id: 'notif-1',
|
||||
householdId: input.householdId,
|
||||
creatorMemberId: input.creatorMemberId,
|
||||
assigneeMemberId: input.assigneeMemberId ?? null,
|
||||
originalRequestText: input.originalRequestText,
|
||||
notificationText: input.notificationText,
|
||||
timezone: input.timezone,
|
||||
scheduledFor: input.scheduledFor,
|
||||
timePrecision: input.timePrecision,
|
||||
deliveryMode: input.deliveryMode,
|
||||
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
|
||||
friendlyTagAssignee: false,
|
||||
status: 'scheduled',
|
||||
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
|
||||
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
|
||||
sentAt: null,
|
||||
cancelledAt: null,
|
||||
cancelledByMemberId: null,
|
||||
createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'),
|
||||
updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||
}
|
||||
}
|
||||
},
|
||||
async listUpcomingNotifications() {
|
||||
return []
|
||||
},
|
||||
async cancelNotification() {
|
||||
return { status: 'not_found' }
|
||||
},
|
||||
async updateNotification() {
|
||||
return { status: 'not_found' }
|
||||
},
|
||||
async listDueNotifications() {
|
||||
return []
|
||||
},
|
||||
async claimDueNotification() {
|
||||
return false
|
||||
},
|
||||
async releaseDueNotification() {},
|
||||
async markNotificationSent() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
reminderInterpreter: {
|
||||
async interpretRequest() {
|
||||
return {
|
||||
decision: 'notification',
|
||||
notificationText: 'починить тебя',
|
||||
assigneeMemberId: null,
|
||||
resolvedLocalDate: null,
|
||||
resolvedHour: null,
|
||||
resolvedMinute: null,
|
||||
relativeOffsetMinutes: 30,
|
||||
dateReferenceMode: null,
|
||||
resolutionMode: 'exact',
|
||||
clarificationQuestion: null,
|
||||
confidence: 94,
|
||||
parserMode: 'llm'
|
||||
}
|
||||
},
|
||||
async interpretSchedule() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async interpretDraftEdit() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async renderDeliveryText() {
|
||||
return 'Пора чинить бота.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await bot.handleUpdate(reminderMessageUpdate('Напомни починить тебя через 30 минут') as never)
|
||||
const pending = await promptRepository.getPendingAction('-10012345', '10002')
|
||||
const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId
|
||||
expect(proposalId).toBeTruthy()
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never)
|
||||
} finally {
|
||||
Temporal.Now.instant = originalNow
|
||||
}
|
||||
|
||||
expect((calls[0]?.payload as { text?: string })?.text).toContain('сегодня в 12:30')
|
||||
expect(scheduledRequests).toEqual([
|
||||
{
|
||||
scheduledFor: '2026-03-24T08:30:00Z'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReminderWhen', () => {
|
||||
|
||||
@@ -775,6 +775,8 @@ export function registerAdHocNotifications(options: {
|
||||
resolvedLocalDate: interpretedSchedule.resolvedLocalDate,
|
||||
resolvedHour: interpretedSchedule.resolvedHour,
|
||||
resolvedMinute: interpretedSchedule.resolvedMinute,
|
||||
relativeOffsetMinutes: interpretedSchedule.relativeOffsetMinutes,
|
||||
dateReferenceMode: interpretedSchedule.dateReferenceMode,
|
||||
resolutionMode: interpretedSchedule.resolutionMode
|
||||
})
|
||||
|
||||
@@ -822,139 +824,148 @@ export function registerAdHocNotifications(options: {
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.reminderInterpreter) {
|
||||
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
if (existingDraft.stage === 'confirm') {
|
||||
if (!options.reminderInterpreter) {
|
||||
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
|
||||
const currentSchedule = draftLocalSchedule(existingDraft)
|
||||
const interpretedEdit = await options.reminderInterpreter.interpretDraftEdit({
|
||||
locale: reminderContext.locale,
|
||||
timezone: existingDraft.timezone,
|
||||
localNow: localNowText(existingDraft.timezone),
|
||||
text: messageText,
|
||||
members: interpreterMembers(reminderContext.members),
|
||||
senderMemberId: reminderContext.member.id,
|
||||
currentNotificationText: existingDraft.normalizedNotificationText,
|
||||
currentAssigneeMemberId: existingDraft.assigneeMemberId,
|
||||
currentScheduledLocalDate: currentSchedule.date,
|
||||
currentScheduledHour: currentSchedule.hour,
|
||||
currentScheduledMinute: currentSchedule.minute,
|
||||
currentDeliveryMode: existingDraft.deliveryMode,
|
||||
currentDmRecipientMemberIds: existingDraft.dmRecipientMemberIds,
|
||||
assistantContext: reminderContext.assistantContext,
|
||||
assistantTone: reminderContext.assistantTone
|
||||
})
|
||||
|
||||
if (!interpretedEdit) {
|
||||
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
|
||||
if (interpretedEdit.decision === 'clarification') {
|
||||
await replyInTopic(
|
||||
ctx,
|
||||
interpretedEdit.clarificationQuestion ??
|
||||
(reminderContext.locale === 'ru'
|
||||
? 'Что именно поправить в напоминании?'
|
||||
: 'What should I adjust in the reminder?')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (interpretedEdit.decision === 'cancel') {
|
||||
await options.promptRepository.clearPendingAction(
|
||||
ctx.chat!.id.toString(),
|
||||
ctx.from!.id.toString()
|
||||
)
|
||||
await replyInTopic(ctx, cancelledDraftReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
|
||||
const scheduleChanged =
|
||||
interpretedEdit.resolvedLocalDate !== null ||
|
||||
interpretedEdit.resolvedHour !== null ||
|
||||
interpretedEdit.resolvedMinute !== null ||
|
||||
interpretedEdit.resolutionMode !== null
|
||||
|
||||
let nextSchedule = {
|
||||
scheduledForIso: existingDraft.scheduledForIso,
|
||||
timePrecision: existingDraft.timePrecision
|
||||
}
|
||||
|
||||
if (scheduleChanged) {
|
||||
const parsedSchedule = parseAdHocNotificationSchedule({
|
||||
const currentSchedule = draftLocalSchedule(existingDraft)
|
||||
const interpretedEdit = await options.reminderInterpreter.interpretDraftEdit({
|
||||
locale: reminderContext.locale,
|
||||
timezone: existingDraft.timezone,
|
||||
resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date,
|
||||
resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour,
|
||||
resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute,
|
||||
resolutionMode: interpretedEdit.resolutionMode ?? 'exact'
|
||||
localNow: localNowText(existingDraft.timezone),
|
||||
text: messageText,
|
||||
members: interpreterMembers(reminderContext.members),
|
||||
senderMemberId: reminderContext.member.id,
|
||||
currentNotificationText: existingDraft.normalizedNotificationText,
|
||||
currentAssigneeMemberId: existingDraft.assigneeMemberId,
|
||||
currentScheduledLocalDate: currentSchedule.date,
|
||||
currentScheduledHour: currentSchedule.hour,
|
||||
currentScheduledMinute: currentSchedule.minute,
|
||||
currentDeliveryMode: existingDraft.deliveryMode,
|
||||
currentDmRecipientMemberIds: existingDraft.dmRecipientMemberIds,
|
||||
assistantContext: reminderContext.assistantContext,
|
||||
assistantTone: reminderContext.assistantTone
|
||||
})
|
||||
|
||||
if (parsedSchedule.kind === 'missing_schedule') {
|
||||
if (!interpretedEdit) {
|
||||
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
|
||||
if (interpretedEdit.decision === 'clarification') {
|
||||
await replyInTopic(
|
||||
ctx,
|
||||
reminderContext.locale === 'ru'
|
||||
? 'Нужны понятные дата или время, чтобы обновить напоминание.'
|
||||
: 'I need a clear date or time to update the reminder.'
|
||||
interpretedEdit.clarificationQuestion ??
|
||||
(reminderContext.locale === 'ru'
|
||||
? 'Что именно поправить в напоминании?'
|
||||
: 'What should I adjust in the reminder?')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (parsedSchedule.kind === 'invalid_past') {
|
||||
await replyInTopic(
|
||||
ctx,
|
||||
reminderContext.locale === 'ru'
|
||||
? 'Это время уже в прошлом. Пришлите будущую дату или время.'
|
||||
: 'That time is already in the past. Send a future date or time.'
|
||||
if (interpretedEdit.decision === 'cancel') {
|
||||
await options.promptRepository.clearPendingAction(
|
||||
ctx.chat!.id.toString(),
|
||||
ctx.from!.id.toString()
|
||||
)
|
||||
await replyInTopic(ctx, cancelledDraftReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
|
||||
nextSchedule = {
|
||||
scheduledForIso: parsedSchedule.scheduledFor!.toString(),
|
||||
timePrecision: parsedSchedule.timePrecision!
|
||||
const scheduleChanged =
|
||||
interpretedEdit.resolvedLocalDate !== null ||
|
||||
interpretedEdit.resolvedHour !== null ||
|
||||
interpretedEdit.resolvedMinute !== null ||
|
||||
interpretedEdit.relativeOffsetMinutes !== null ||
|
||||
interpretedEdit.resolutionMode !== null
|
||||
|
||||
let nextSchedule = {
|
||||
scheduledForIso: existingDraft.scheduledForIso,
|
||||
timePrecision: existingDraft.timePrecision
|
||||
}
|
||||
}
|
||||
|
||||
const nextNormalizedNotificationText =
|
||||
interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText
|
||||
const nextOriginalRequestText =
|
||||
interpretedEdit.notificationText !== null ? messageText : existingDraft.originalRequestText
|
||||
const nextAssigneeMemberId = interpretedEdit.assigneeChanged
|
||||
? interpretedEdit.assigneeMemberId
|
||||
: existingDraft.assigneeMemberId
|
||||
const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode
|
||||
const nextDmRecipientMemberIds =
|
||||
interpretedEdit.dmRecipientMemberIds ??
|
||||
(nextDeliveryMode === existingDraft.deliveryMode ? existingDraft.dmRecipientMemberIds : [])
|
||||
if (scheduleChanged) {
|
||||
const parsedSchedule = parseAdHocNotificationSchedule({
|
||||
timezone: existingDraft.timezone,
|
||||
resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date,
|
||||
resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour,
|
||||
resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute,
|
||||
relativeOffsetMinutes: interpretedEdit.relativeOffsetMinutes,
|
||||
dateReferenceMode: interpretedEdit.dateReferenceMode,
|
||||
resolutionMode: interpretedEdit.resolutionMode ?? 'exact'
|
||||
})
|
||||
|
||||
const renderedNotificationText = await renderNotificationText({
|
||||
reminderContext,
|
||||
originalRequestText: nextOriginalRequestText,
|
||||
normalizedNotificationText: nextNormalizedNotificationText,
|
||||
assigneeMemberId: nextAssigneeMemberId
|
||||
})
|
||||
if (!renderedNotificationText) {
|
||||
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||
if (parsedSchedule.kind === 'missing_schedule') {
|
||||
await replyInTopic(
|
||||
ctx,
|
||||
reminderContext.locale === 'ru'
|
||||
? 'Нужны понятные дата или время, чтобы обновить напоминание.'
|
||||
: 'I need a clear date or time to update the reminder.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (parsedSchedule.kind === 'invalid_past') {
|
||||
await replyInTopic(
|
||||
ctx,
|
||||
reminderContext.locale === 'ru'
|
||||
? 'Это время уже в прошлом. Пришлите будущую дату или время.'
|
||||
: 'That time is already in the past. Send a future date or time.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
nextSchedule = {
|
||||
scheduledForIso: parsedSchedule.scheduledFor!.toString(),
|
||||
timePrecision: parsedSchedule.timePrecision!
|
||||
}
|
||||
}
|
||||
|
||||
const nextNormalizedNotificationText =
|
||||
interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText
|
||||
const nextOriginalRequestText =
|
||||
interpretedEdit.notificationText !== null
|
||||
? messageText
|
||||
: existingDraft.originalRequestText
|
||||
const nextAssigneeMemberId = interpretedEdit.assigneeChanged
|
||||
? interpretedEdit.assigneeMemberId
|
||||
: existingDraft.assigneeMemberId
|
||||
const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode
|
||||
const nextDmRecipientMemberIds =
|
||||
interpretedEdit.dmRecipientMemberIds ??
|
||||
(nextDeliveryMode === existingDraft.deliveryMode
|
||||
? existingDraft.dmRecipientMemberIds
|
||||
: [])
|
||||
|
||||
const renderedNotificationText = await renderNotificationText({
|
||||
reminderContext,
|
||||
originalRequestText: nextOriginalRequestText,
|
||||
normalizedNotificationText: nextNormalizedNotificationText,
|
||||
assigneeMemberId: nextAssigneeMemberId
|
||||
})
|
||||
if (!renderedNotificationText) {
|
||||
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||
return
|
||||
}
|
||||
|
||||
const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||
...existingDraft,
|
||||
originalRequestText: nextOriginalRequestText,
|
||||
normalizedNotificationText: nextNormalizedNotificationText,
|
||||
renderedNotificationText,
|
||||
assigneeMemberId: nextAssigneeMemberId,
|
||||
scheduledForIso: nextSchedule.scheduledForIso,
|
||||
timePrecision: nextSchedule.timePrecision,
|
||||
deliveryMode: nextDeliveryMode,
|
||||
dmRecipientMemberIds: nextDmRecipientMemberIds,
|
||||
viewMode: 'compact'
|
||||
}
|
||||
|
||||
await showDraftConfirmation(ctx, nextPayload, existingDraft.confirmationMessageId)
|
||||
return
|
||||
}
|
||||
|
||||
const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||
...existingDraft,
|
||||
originalRequestText: nextOriginalRequestText,
|
||||
normalizedNotificationText: nextNormalizedNotificationText,
|
||||
renderedNotificationText,
|
||||
assigneeMemberId: nextAssigneeMemberId,
|
||||
scheduledForIso: nextSchedule.scheduledForIso,
|
||||
timePrecision: nextSchedule.timePrecision,
|
||||
deliveryMode: nextDeliveryMode,
|
||||
dmRecipientMemberIds: nextDmRecipientMemberIds,
|
||||
viewMode: 'compact'
|
||||
}
|
||||
|
||||
await showDraftConfirmation(ctx, nextPayload, existingDraft.confirmationMessageId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.reminderInterpreter) {
|
||||
@@ -1025,6 +1036,8 @@ export function registerAdHocNotifications(options: {
|
||||
resolvedLocalDate: interpretedRequest.resolvedLocalDate,
|
||||
resolvedHour: interpretedRequest.resolvedHour,
|
||||
resolvedMinute: interpretedRequest.resolvedMinute,
|
||||
relativeOffsetMinutes: interpretedRequest.relativeOffsetMinutes,
|
||||
dateReferenceMode: interpretedRequest.dateReferenceMode,
|
||||
resolutionMode: interpretedRequest.resolutionMode
|
||||
})
|
||||
|
||||
@@ -1167,6 +1180,7 @@ export function registerAdHocNotifications(options: {
|
||||
const payload = await loadDraft(options.promptRepository, ctx)
|
||||
if (
|
||||
!payload ||
|
||||
payload.stage !== 'confirm' ||
|
||||
payload.proposalId !== proposalId ||
|
||||
!ctx.chat ||
|
||||
!ctx.from ||
|
||||
|
||||
@@ -47,6 +47,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 15,
|
||||
resolvedMinute: 30,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'exact',
|
||||
confidence: 93,
|
||||
clarificationQuestion: null
|
||||
@@ -72,6 +74,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 15,
|
||||
resolvedMinute: 30,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'exact',
|
||||
clarificationQuestion: null,
|
||||
confidence: 93,
|
||||
@@ -100,6 +104,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 9,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'fuzzy_window',
|
||||
confidence: 90,
|
||||
clarificationQuestion: null
|
||||
@@ -141,6 +147,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
resolvedLocalDate: null,
|
||||
resolvedHour: null,
|
||||
resolvedMinute: null,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: null,
|
||||
resolutionMode: 'ambiguous',
|
||||
confidence: 82,
|
||||
clarificationQuestion: 'Когда напомнить: завтра утром, днем или вечером?'
|
||||
@@ -185,6 +193,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
resolvedLocalDate: null,
|
||||
resolvedHour: null,
|
||||
resolvedMinute: null,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: null,
|
||||
resolutionMode: null,
|
||||
confidence: 96,
|
||||
clarificationQuestion: null
|
||||
@@ -225,6 +235,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
resolvedLocalDate: '2026-03-24',
|
||||
resolvedHour: 10,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'exact',
|
||||
deliveryMode: null,
|
||||
dmRecipientMemberIds: null,
|
||||
@@ -256,6 +268,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
|
||||
decision: 'updated',
|
||||
resolvedHour: 10,
|
||||
resolvedMinute: 0,
|
||||
relativeOffsetMinutes: null,
|
||||
dateReferenceMode: 'relative',
|
||||
resolutionMode: 'exact',
|
||||
notificationText: null,
|
||||
deliveryMode: null
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AdHocNotificationInterpretation {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes: number | null
|
||||
dateReferenceMode: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
clarificationQuestion: string | null
|
||||
confidence: number
|
||||
@@ -28,6 +30,8 @@ export interface AdHocNotificationScheduleInterpretation {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes: number | null
|
||||
dateReferenceMode: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
clarificationQuestion: string | null
|
||||
confidence: number
|
||||
@@ -42,6 +46,8 @@ export interface AdHocNotificationDraftEditInterpretation {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes: number | null
|
||||
dateReferenceMode: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
deliveryMode: AdHocNotificationDeliveryMode | null
|
||||
dmRecipientMemberIds: readonly string[] | null
|
||||
@@ -57,6 +63,8 @@ interface ReminderInterpretationResult {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes: number | null
|
||||
dateReferenceMode: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
confidence: number
|
||||
clarificationQuestion: string | null
|
||||
@@ -67,6 +75,8 @@ interface ReminderScheduleResult {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes: number | null
|
||||
dateReferenceMode: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
confidence: number
|
||||
clarificationQuestion: string | null
|
||||
@@ -84,6 +94,8 @@ interface ReminderDraftEditResult {
|
||||
resolvedLocalDate: string | null
|
||||
resolvedHour: number | null
|
||||
resolvedMinute: number | null
|
||||
relativeOffsetMinutes: number | null
|
||||
dateReferenceMode: 'relative' | 'calendar' | null
|
||||
resolutionMode: AdHocNotificationResolutionMode | null
|
||||
deliveryMode: AdHocNotificationDeliveryMode | null
|
||||
dmRecipientMemberIds: string[] | null
|
||||
@@ -204,6 +216,26 @@ function normalizeMinute(value: number | null | undefined): number | null {
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeRelativeOffsetMinutes(value: number | null | undefined): number | null {
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
!Number.isInteger(value) ||
|
||||
value <= 0 ||
|
||||
value > 7 * 24 * 60
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeDateReferenceMode(
|
||||
value: string | null | undefined
|
||||
): 'relative' | 'calendar' | null {
|
||||
return value === 'relative' || value === 'calendar' ? value : null
|
||||
}
|
||||
|
||||
function normalizeMemberId(
|
||||
value: string | null | undefined,
|
||||
members: readonly AdHocNotificationInterpreterMember[]
|
||||
@@ -348,6 +380,18 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
resolvedMinute: {
|
||||
anyOf: [{ type: 'integer' }, { type: 'null' }]
|
||||
},
|
||||
relativeOffsetMinutes: {
|
||||
anyOf: [{ type: 'integer' }, { type: 'null' }]
|
||||
},
|
||||
dateReferenceMode: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
enum: ['relative', 'calendar']
|
||||
},
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
resolutionMode: {
|
||||
anyOf: [
|
||||
{
|
||||
@@ -373,6 +417,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
'resolvedLocalDate',
|
||||
'resolvedHour',
|
||||
'resolvedMinute',
|
||||
'relativeOffsetMinutes',
|
||||
'dateReferenceMode',
|
||||
'resolutionMode',
|
||||
'confidence',
|
||||
'clarificationQuestion'
|
||||
@@ -385,9 +431,12 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
'Use the provided member ids when a reminder is clearly aimed at a specific household member.',
|
||||
'resolvedLocalDate must be YYYY-MM-DD in the provided household timezone.',
|
||||
'resolvedHour and resolvedMinute must be in 24-hour local time when a reminder can be scheduled.',
|
||||
'If the user uses a relative duration like "через 30 минут" or "in 2 hours", prefer relativeOffsetMinutes over absolute date/time fields.',
|
||||
'dateReferenceMode must be relative for words like today, tomorrow, the day after tomorrow, today evening, tomorrow morning; use calendar only for explicit calendar dates.',
|
||||
'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without an explicit time, ambiguous when the request is still too vague to schedule.',
|
||||
'If schedule information is missing or ambiguous, return decision clarification and a short clarificationQuestion in the user language.',
|
||||
'If the message is not a reminder request, return decision not_notification.',
|
||||
'During local 00:00-04:59, vague tomorrow phrases like "завтра", "завтра утром", "tomorrow", "tomorrow morning" refer to the upcoming same-calendar-day reminder window unless the user gave an explicit calendar date.',
|
||||
promptWindowRules(),
|
||||
options.assistantContext ? `Household context: ${options.assistantContext}` : null,
|
||||
options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null,
|
||||
@@ -419,6 +468,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
|
||||
resolvedHour: normalizeHour(parsed.resolvedHour),
|
||||
resolvedMinute: normalizeMinute(parsed.resolvedMinute),
|
||||
relativeOffsetMinutes: normalizeRelativeOffsetMinutes(parsed.relativeOffsetMinutes),
|
||||
dateReferenceMode: normalizeDateReferenceMode(parsed.dateReferenceMode),
|
||||
resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
|
||||
clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion),
|
||||
confidence: normalizeConfidence(parsed.confidence),
|
||||
@@ -448,6 +499,18 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
resolvedMinute: {
|
||||
anyOf: [{ type: 'integer' }, { type: 'null' }]
|
||||
},
|
||||
relativeOffsetMinutes: {
|
||||
anyOf: [{ type: 'integer' }, { type: 'null' }]
|
||||
},
|
||||
dateReferenceMode: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
enum: ['relative', 'calendar']
|
||||
},
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
resolutionMode: {
|
||||
anyOf: [
|
||||
{
|
||||
@@ -471,6 +534,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
'resolvedLocalDate',
|
||||
'resolvedHour',
|
||||
'resolvedMinute',
|
||||
'relativeOffsetMinutes',
|
||||
'dateReferenceMode',
|
||||
'resolutionMode',
|
||||
'confidence',
|
||||
'clarificationQuestion'
|
||||
@@ -481,8 +546,11 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
'Decide whether the message contains enough schedule information to produce a local date/time or whether you need clarification.',
|
||||
'resolvedLocalDate must be YYYY-MM-DD in the provided household timezone.',
|
||||
'resolvedHour and resolvedMinute must be local 24-hour time when parsed.',
|
||||
'If the message uses a relative duration like "через 30 минут" or "in 2 hours", prefer relativeOffsetMinutes over absolute date/time fields.',
|
||||
'dateReferenceMode must be relative for words like today, tomorrow, and the day after tomorrow; use calendar only for explicit calendar dates.',
|
||||
'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.',
|
||||
'If the schedule is missing or ambiguous, return clarification and ask a short question in the user language.',
|
||||
'During local 00:00-04:59, vague tomorrow phrases like "завтра", "завтра утром", "tomorrow", "tomorrow morning" refer to the upcoming same-calendar-day reminder window unless the user gave an explicit calendar date.',
|
||||
promptWindowRules(),
|
||||
`Household timezone: ${options.timezone}`,
|
||||
`Current local date/time in that timezone: ${options.localNow}`,
|
||||
@@ -502,6 +570,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
|
||||
resolvedHour: normalizeHour(parsed.resolvedHour),
|
||||
resolvedMinute: normalizeMinute(parsed.resolvedMinute),
|
||||
relativeOffsetMinutes: normalizeRelativeOffsetMinutes(parsed.relativeOffsetMinutes),
|
||||
dateReferenceMode: normalizeDateReferenceMode(parsed.dateReferenceMode),
|
||||
resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
|
||||
clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion),
|
||||
confidence: normalizeConfidence(parsed.confidence),
|
||||
@@ -540,6 +610,18 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
resolvedMinute: {
|
||||
anyOf: [{ type: 'integer' }, { type: 'null' }]
|
||||
},
|
||||
relativeOffsetMinutes: {
|
||||
anyOf: [{ type: 'integer' }, { type: 'null' }]
|
||||
},
|
||||
dateReferenceMode: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
enum: ['relative', 'calendar']
|
||||
},
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
resolutionMode: {
|
||||
anyOf: [
|
||||
{
|
||||
@@ -586,6 +668,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
'resolvedLocalDate',
|
||||
'resolvedHour',
|
||||
'resolvedMinute',
|
||||
'relativeOffsetMinutes',
|
||||
'dateReferenceMode',
|
||||
'resolutionMode',
|
||||
'deliveryMode',
|
||||
'dmRecipientMemberIds',
|
||||
@@ -601,8 +685,11 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
'Use notificationText only when the user changes what should be reminded.',
|
||||
'Use deliveryMode when the user changes where the reminder should be sent.',
|
||||
'Use dmRecipientMemberIds only when the user clearly changes selected DM recipients.',
|
||||
'If the user uses a relative duration like "через 30 минут" or "in 2 hours", prefer relativeOffsetMinutes over absolute date/time fields.',
|
||||
'dateReferenceMode must be relative for words like today, tomorrow, and the day after tomorrow; use calendar only for explicit calendar dates.',
|
||||
'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.',
|
||||
'If the latest message is too ambiguous, return clarification and a short clarificationQuestion in the user language.',
|
||||
'During local 00:00-04:59, vague tomorrow phrases like "завтра", "завтра утром", "tomorrow", "tomorrow morning" refer to the upcoming same-calendar-day reminder window unless the user gave an explicit calendar date.',
|
||||
promptWindowRules(),
|
||||
options.assistantContext ? `Household context: ${options.assistantContext}` : null,
|
||||
options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null,
|
||||
@@ -643,6 +730,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
|
||||
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
|
||||
resolvedHour: normalizeHour(parsed.resolvedHour),
|
||||
resolvedMinute: normalizeMinute(parsed.resolvedMinute),
|
||||
relativeOffsetMinutes: normalizeRelativeOffsetMinutes(parsed.relativeOffsetMinutes),
|
||||
dateReferenceMode: normalizeDateReferenceMode(parsed.dateReferenceMode),
|
||||
resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
|
||||
deliveryMode: normalizeDeliveryMode(parsed.deliveryMode),
|
||||
dmRecipientMemberIds: normalizeMemberIds(parsed.dmRecipientMemberIds, options.members),
|
||||
|
||||
@@ -68,6 +68,9 @@ describe('createOpenAiChatAssistant', () => {
|
||||
expect(capturedBody!.input[1]?.content).toContain(
|
||||
'Members can ask the bot to schedule a future notification in this topic.'
|
||||
)
|
||||
expect(capturedBody!.input[1]?.content).toContain(
|
||||
'Never tell the user to set a reminder on their own device in this topic.'
|
||||
)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ function topicCapabilityNotes(topicRole: TopicMessageRole): string {
|
||||
'- You can discuss existing household rent/utilities reminder timing, the supported utility-bill collection flow, and ad hoc household notifications.',
|
||||
'- Members can ask the bot to schedule a future notification in this topic.',
|
||||
'- If the date or time is missing, ask a concise follow-up instead of pretending it was scheduled.',
|
||||
'- Do not claim a notification was saved unless the system explicitly confirmed it.'
|
||||
'- Do not claim a notification was saved unless the system explicitly confirmed it.',
|
||||
'- Never tell the user to set a reminder on their own device in this topic.'
|
||||
].join('\n')
|
||||
case 'feedback':
|
||||
return [
|
||||
@@ -111,7 +112,7 @@ const ASSISTANT_SYSTEM_PROMPT = [
|
||||
'Do not repeat the same clarification after the user declines, backs off, or says they are only thinking.',
|
||||
'Do not restate the full household context unless the user explicitly asks for details.',
|
||||
'Do not imply capabilities that are not explicitly provided in the system context.',
|
||||
'There is no general feature for creating or scheduling arbitrary personal reminders unless the system explicitly says so.',
|
||||
'There is no general feature for creating or scheduling arbitrary personal reminders unless the system explicitly says so in the current topic capability notes.',
|
||||
'Avoid bullet lists unless the user asked for a list or several distinct items.',
|
||||
'Reply in the user language inferred from the latest user message and locale context.'
|
||||
].join(' ')
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('createAdHocNotificationService', () => {
|
||||
originalRequestText: 'Напомни Георгию завтра',
|
||||
notificationText: 'пошпынять Георгия о том, позвонил ли он',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic'
|
||||
})
|
||||
@@ -222,7 +222,7 @@ describe('createAdHocNotificationService', () => {
|
||||
originalRequestText: 'remind everyone tomorrow',
|
||||
notificationText: 'pay rent',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'dm_all'
|
||||
})
|
||||
@@ -246,7 +246,7 @@ describe('createAdHocNotificationService', () => {
|
||||
originalRequestText: 'remind tomorrow',
|
||||
notificationText: 'check rent',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic',
|
||||
friendlyTagAssignee: true
|
||||
@@ -273,7 +273,7 @@ describe('createAdHocNotificationService', () => {
|
||||
originalRequestText: 'remind tomorrow',
|
||||
notificationText: 'call landlord',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic',
|
||||
friendlyTagAssignee: false
|
||||
@@ -306,7 +306,7 @@ describe('createAdHocNotificationService', () => {
|
||||
originalRequestText: 'remind tomorrow',
|
||||
notificationText: 'call landlord',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic',
|
||||
friendlyTagAssignee: false
|
||||
@@ -342,7 +342,7 @@ describe('createAdHocNotificationService', () => {
|
||||
originalRequestText: 'remind tomorrow',
|
||||
notificationText: 'call landlord',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
|
||||
timePrecision: 'date_only_defaulted',
|
||||
deliveryMode: 'topic',
|
||||
friendlyTagAssignee: false
|
||||
@@ -351,7 +351,7 @@ describe('createAdHocNotificationService', () => {
|
||||
const result = await service.updateNotification({
|
||||
notificationId: created.id,
|
||||
viewerMemberId: 'creator',
|
||||
scheduledFor: Temporal.Instant.from('2026-03-24T09:00:00Z'),
|
||||
scheduledFor: Temporal.Instant.from('2026-03-25T09:00:00Z'),
|
||||
timePrecision: 'exact',
|
||||
deliveryMode: 'dm_selected',
|
||||
dmRecipientMemberIds: ['alice', 'bob'],
|
||||
@@ -360,7 +360,7 @@ describe('createAdHocNotificationService', () => {
|
||||
|
||||
expect(result.status).toBe('updated')
|
||||
if (result.status === 'updated') {
|
||||
expect(result.notification.scheduledFor.toString()).toBe('2026-03-24T09:00:00Z')
|
||||
expect(result.notification.scheduledFor.toString()).toBe('2026-03-25T09:00:00Z')
|
||||
expect(result.notification.deliveryMode).toBe('dm_selected')
|
||||
expect(result.notification.dmRecipientMemberIds).toEqual(['alice', 'bob'])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user