diff --git a/apps/bot/src/ad-hoc-notification-parser.test.ts b/apps/bot/src/ad-hoc-notification-parser.test.ts index 9987d4b..5215941 100644 --- a/apps/bot/src/ad-hoc-notification-parser.test.ts +++ b/apps/bot/src/ad-hoc-notification-parser.test.ts @@ -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') + }) }) diff --git a/apps/bot/src/ad-hoc-notification-parser.ts b/apps/bot/src/ad-hoc-notification-parser.ts index ce3fb04..167357a 100644 --- a/apps/bot/src/ad-hoc-notification-parser.ts +++ b/apps/bot/src/ad-hoc-notification-parser.ts @@ -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', diff --git a/apps/bot/src/ad-hoc-notifications.test.ts b/apps/bot/src/ad-hoc-notifications.test.ts index 314ed1e..9493066 100644 --- a/apps/bot/src/ad-hoc-notifications.test.ts +++ b/apps/bot/src/ad-hoc-notifications.test.ts @@ -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() 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', () => { diff --git a/apps/bot/src/ad-hoc-notifications.ts b/apps/bot/src/ad-hoc-notifications.ts index 8701522..5faf9a2 100644 --- a/apps/bot/src/ad-hoc-notifications.ts +++ b/apps/bot/src/ad-hoc-notifications.ts @@ -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 = { + ...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 = { - ...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 || diff --git a/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts index 7196e73..57146d6 100644 --- a/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts @@ -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 diff --git a/apps/bot/src/openai-ad-hoc-notification-interpreter.ts b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts index a93b39c..a2ee242 100644 --- a/apps/bot/src/openai-ad-hoc-notification-interpreter.ts +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts @@ -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), diff --git a/apps/bot/src/openai-chat-assistant.test.ts b/apps/bot/src/openai-chat-assistant.test.ts index af8e657..cce9c5e 100644 --- a/apps/bot/src/openai-chat-assistant.test.ts +++ b/apps/bot/src/openai-chat-assistant.test.ts @@ -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 } diff --git a/apps/bot/src/openai-chat-assistant.ts b/apps/bot/src/openai-chat-assistant.ts index 11dae9e..68b5bf4 100644 --- a/apps/bot/src/openai-chat-assistant.ts +++ b/apps/bot/src/openai-chat-assistant.ts @@ -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(' ') diff --git a/packages/application/src/ad-hoc-notification-service.test.ts b/packages/application/src/ad-hoc-notification-service.test.ts index fcf26e8..4e45f2d 100644 --- a/packages/application/src/ad-hoc-notification-service.test.ts +++ b/packages/application/src/ad-hoc-notification-service.test.ts @@ -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']) }