feat(bot): simplify reminder confirmation flow

This commit is contained in:
2026-03-24 03:18:50 +04:00
parent 3112fd6b0d
commit 7e9ae75a41
4 changed files with 773 additions and 60 deletions

View File

@@ -9,9 +9,10 @@ import type {
TelegramPendingActionRecord, TelegramPendingActionRecord,
TelegramPendingActionRepository TelegramPendingActionRepository
} from '@household/ports' } from '@household/ports'
import type { InlineKeyboardMarkup } from 'grammy/types'
import { createTelegramBot } from './bot' import { createTelegramBot } from './bot'
import { registerAdHocNotifications } from './ad-hoc-notifications' import { formatReminderWhen, registerAdHocNotifications } from './ad-hoc-notifications'
import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter'
function createPromptRepository(): TelegramPendingActionRepository { function createPromptRepository(): TelegramPendingActionRepository {
@@ -169,11 +170,33 @@ function createHouseholdRepository() {
} }
describe('registerAdHocNotifications', () => { describe('registerAdHocNotifications', () => {
test('shows the final rendered reminder text and persists that same text on confirm', async () => { test('shows a compact playful confirmation, supports time edits, and persists the hidden rendered text on confirm', async () => {
const bot = createTelegramBot('000000:test-token') const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []
const promptRepository = createPromptRepository() const promptRepository = createPromptRepository()
const scheduledRequests: Array<{ notificationText: string }> = [] const scheduledRequests: Array<{ notificationText: string }> = []
const now = Temporal.Now.instant()
const localNow = now.toZonedDateTimeISO('Asia/Tbilisi')
const baseDate =
localNow.hour <= 4 ? localNow.toPlainDate().subtract({ days: 1 }) : localNow.toPlainDate()
const tomorrow = baseDate.add({ days: 1 }).toString()
let draftEditCalls = 0
const initialWhen = formatReminderWhen({
locale: 'ru',
scheduledForIso: Temporal.ZonedDateTime.from(`${tomorrow}T09:00:00[Asia/Tbilisi]`)
.toInstant()
.toString(),
timezone: 'Asia/Tbilisi',
now
})
const updatedWhen = formatReminderWhen({
locale: 'ru',
scheduledForIso: Temporal.ZonedDateTime.from(`${tomorrow}T10:00:00[Asia/Tbilisi]`)
.toInstant()
.toString(),
timezone: 'Asia/Tbilisi',
now
})
bot.botInfo = { bot.botInfo = {
id: 999000, id: 999000,
@@ -212,7 +235,7 @@ describe('registerAdHocNotifications', () => {
decision: 'notification', decision: 'notification',
notificationText: 'пошпынять Георгия о том, позвонил ли он', notificationText: 'пошпынять Георгия о том, позвонил ли он',
assigneeMemberId: 'georgiy', assigneeMemberId: 'georgiy',
resolvedLocalDate: '2026-03-24', resolvedLocalDate: tomorrow,
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
@@ -224,7 +247,7 @@ describe('registerAdHocNotifications', () => {
async interpretSchedule() { async interpretSchedule() {
return { return {
decision: 'parsed', decision: 'parsed',
resolvedLocalDate: '2026-03-24', resolvedLocalDate: tomorrow,
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
@@ -233,6 +256,24 @@ describe('registerAdHocNotifications', () => {
parserMode: 'llm' parserMode: 'llm'
} }
}, },
async interpretDraftEdit() {
draftEditCalls += 1
return {
decision: 'updated',
notificationText: null,
assigneeChanged: false,
assigneeMemberId: null,
resolvedLocalDate: tomorrow,
resolvedHour: 10,
resolvedMinute: 0,
resolutionMode: 'exact',
deliveryMode: null,
dmRecipientMemberIds: null,
clarificationQuestion: null,
confidence: 90,
parserMode: 'llm'
}
},
async renderDeliveryText(input) { async renderDeliveryText(input) {
expect(input.requesterDisplayName).toBe('Дима') expect(input.requesterDisplayName).toBe('Дима')
expect(input.assigneeDisplayName).toBe('Георгий') expect(input.assigneeDisplayName).toBe('Георгий')
@@ -301,15 +342,31 @@ describe('registerAdHocNotifications', () => {
expect(calls[0]?.method).toBe('sendMessage') expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({ expect(calls[0]?.payload).toMatchObject({
text: expect.stringContaining('Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.') text: `Окей, ${initialWhen} напомню.`
}) })
expect((calls[0]?.payload as { text?: string })?.text).not.toContain(
'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.'
)
const pending = await promptRepository.getPendingAction('-10012345', '10002') const pending = await promptRepository.getPendingAction('-10012345', '10002')
const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId
expect(proposalId).toBeTruthy() expect(proposalId).toBeTruthy()
await bot.handleUpdate(reminderMessageUpdate('Давай на 10 часов лучше') as never)
expect(draftEditCalls).toBe(1)
expect(calls[1]?.payload).toMatchObject({
text: `Окей, ${updatedWhen} напомню.`
})
await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never) await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never)
expect(calls[2]?.method).toBe('answerCallbackQuery')
expect(calls[3]?.method).toBe('editMessageText')
expect(calls[3]?.payload).toMatchObject({
text: `Окей, ${updatedWhen} напомню.`
})
expect(scheduledRequests).toEqual([ expect(scheduledRequests).toEqual([
{ {
notificationText: 'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.' notificationText: 'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.'
@@ -378,4 +435,136 @@ describe('registerAdHocNotifications', () => {
text: 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.' text: 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.'
}) })
}) })
test('expands advanced controls inline', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
const now = Temporal.Now.instant()
const localNow = now.toZonedDateTimeISO('Asia/Tbilisi')
const baseDate =
localNow.hour <= 4 ? localNow.toPlainDate().subtract({ days: 1 }) : localNow.toPlainDate()
const tomorrow = baseDate.add({ days: 1 }).toString()
const expectedWhen = formatReminderWhen({
locale: 'ru',
scheduledForIso: Temporal.ZonedDateTime.from(`${tomorrow}T09:00:00[Asia/Tbilisi]`)
.toInstant()
.toString(),
timezone: 'Asia/Tbilisi',
now
})
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
})
registerAdHocNotifications({
bot,
householdConfigurationRepository: createHouseholdRepository() as never,
promptRepository: createPromptRepository(),
notificationService: {
async scheduleNotification() {
throw new Error('not used')
},
async listUpcomingNotifications() {
return []
},
async cancelNotification() {
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: 'dima',
resolvedLocalDate: tomorrow,
resolvedHour: 9,
resolvedMinute: 0,
resolutionMode: 'fuzzy_window',
clarificationQuestion: null,
confidence: 90,
parserMode: 'llm'
}
},
async interpretSchedule() {
throw new Error('not used')
},
async interpretDraftEdit() {
throw new Error('not used')
},
async renderDeliveryText() {
return 'Стас, не забудь покушать.'
}
}
})
await bot.handleUpdate(reminderMessageUpdate('Напомни завтра с утра покушать') as never)
const firstPayload = calls[0]?.payload as { reply_markup?: InlineKeyboardMarkup; text?: string }
const moreButton = firstPayload.reply_markup?.inline_keyboard[0]?.[2] as
| { text?: string; callback_data?: string }
| undefined
expect(moreButton?.text).toBe('Еще')
expect(firstPayload.text).toBe(`Окей, ${expectedWhen} напомню.`)
const callbackData = moreButton?.callback_data
expect(callbackData).toBeTruthy()
await bot.handleUpdate(reminderCallbackUpdate(callbackData ?? 'missing') as never)
expect(calls[1]?.method).toBe('editMessageText')
const expandedPayload = calls[1]?.payload as { reply_markup?: InlineKeyboardMarkup }
expect(expandedPayload.reply_markup?.inline_keyboard[0]?.[2]?.text).toBe('Скрыть')
expect(expandedPayload.reply_markup?.inline_keyboard[1]?.[0]?.text).toContain('В топик')
})
})
describe('formatReminderWhen', () => {
test('uses sleep-aware tomorrow wording for the upcoming morning', () => {
expect(
formatReminderWhen({
locale: 'ru',
scheduledForIso: '2026-03-24T05:00:00Z',
timezone: 'Asia/Tbilisi',
now: Temporal.Instant.from('2026-03-23T21:00:00Z')
})
).toBe('завтра в 9 утра')
})
}) })

View File

@@ -26,6 +26,7 @@ const AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX = 'adhocnotif:canceldraft:'
const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:' const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:'
const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:' const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:'
const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:' const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:'
const AD_HOC_NOTIFICATION_VIEW_PREFIX = 'adhocnotif:view:'
type NotificationDraftPayload = type NotificationDraftPayload =
| { | {
@@ -56,6 +57,7 @@ type NotificationDraftPayload =
timePrecision: 'exact' | 'date_only_defaulted' timePrecision: 'exact' | 'date_only_defaulted'
deliveryMode: AdHocNotificationDeliveryMode deliveryMode: AdHocNotificationDeliveryMode
dmRecipientMemberIds: readonly string[] dmRecipientMemberIds: readonly string[]
viewMode: 'compact' | 'expanded'
} }
interface ReminderTopicContext { interface ReminderTopicContext {
@@ -139,6 +141,82 @@ function formatScheduledFor(locale: BotLocale, scheduledForIso: string, timezone
return `${date} ${time} (${timezone})` return `${date} ${time} (${timezone})`
} }
function formatTimeOfDay(locale: BotLocale, hour: number, minute: number): string {
const exact = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
if (locale !== 'ru' || minute !== 0) {
return locale === 'ru' ? `в ${exact}` : `at ${exact}`
}
if (hour >= 5 && hour <= 11) {
return `в ${hour} утра`
}
if (hour >= 12 && hour <= 16) {
return hour === 12 ? 'в 12 дня' : `в ${hour} дня`
}
if (hour >= 17 && hour <= 23) {
return hour === 18 ? 'в 6 вечера' : `в ${hour > 12 ? hour - 12 : hour} вечера`
}
return `в ${hour} ночи`
}
function relativeDayLabel(input: {
locale: BotLocale
now: Temporal.ZonedDateTime
target: Temporal.ZonedDateTime
}): string | null {
const targetDate = input.target.toPlainDate()
const nowDate = input.now.toPlainDate()
const tomorrow = nowDate.add({ days: 1 })
const dayAfterTomorrow = nowDate.add({ days: 2 })
const sleepAwareCurrentDate = input.now.hour <= 4 ? nowDate.subtract({ days: 1 }) : nowDate
const sleepAwareTomorrow = sleepAwareCurrentDate.add({ days: 1 })
const sleepAwareDayAfterTomorrow = sleepAwareCurrentDate.add({ days: 2 })
if (targetDate.equals(sleepAwareCurrentDate)) {
return input.locale === 'ru' ? 'сегодня' : 'today'
}
if (targetDate.equals(sleepAwareTomorrow)) {
return input.locale === 'ru' ? 'завтра' : 'tomorrow'
}
if (targetDate.equals(sleepAwareDayAfterTomorrow)) {
return input.locale === 'ru' ? 'послезавтра' : 'the day after tomorrow'
}
if (targetDate.equals(tomorrow)) {
return input.locale === 'ru' ? 'завтра' : 'tomorrow'
}
if (targetDate.equals(dayAfterTomorrow)) {
return input.locale === 'ru' ? 'послезавтра' : 'the day after tomorrow'
}
return null
}
export function formatReminderWhen(input: {
locale: BotLocale
scheduledForIso: string
timezone: string
now?: Temporal.Instant
}): string {
const now = (input.now ?? nowInstant()).toZonedDateTimeISO(input.timezone)
const target = Temporal.Instant.from(input.scheduledForIso).toZonedDateTimeISO(input.timezone)
const relativeDay = relativeDayLabel({
locale: input.locale,
now,
target
})
const timeText = formatTimeOfDay(input.locale, target.hour, target.minute)
if (relativeDay) {
return input.locale === 'ru' ? `${relativeDay} ${timeText}` : `${relativeDay} ${timeText}`
}
return input.locale === 'ru'
? `${formatScheduledFor(input.locale, input.scheduledForIso, input.timezone)}`
: formatScheduledFor(input.locale, input.scheduledForIso, input.timezone)
}
function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string { function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string {
if (locale === 'ru') { if (locale === 'ru') {
switch (mode) { switch (mode) {
@@ -166,49 +244,49 @@ function notificationSummaryText(input: {
payload: Extract<NotificationDraftPayload, { stage: 'confirm' }> payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>
members: readonly HouseholdMemberRecord[] members: readonly HouseholdMemberRecord[]
}): string { }): string {
const assignee = input.payload.assigneeMemberId
? input.members.find((member) => member.id === input.payload.assigneeMemberId)
: null
const selectedRecipients =
input.payload.deliveryMode === 'dm_selected'
? input.members.filter((member) => input.payload.dmRecipientMemberIds.includes(member.id))
: []
if (input.locale === 'ru') { if (input.locale === 'ru') {
return [ const base = `Окей, ${formatReminderWhen({
'Запланировать напоминание?', locale: input.locale,
'', scheduledForIso: input.payload.scheduledForIso,
`Текст напоминания: ${input.payload.renderedNotificationText}`, timezone: input.payload.timezone
`Когда: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, })} напомню.`
`Точность: ${input.payload.timePrecision === 'date_only_defaulted' ? 'время определено ботом' : 'точное время'}`, if (input.payload.deliveryMode === 'topic') {
`Куда: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, return base
assignee ? `Ответственный: ${assignee.displayName}` : null, }
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 if (input.payload.deliveryMode === 'dm_all') {
? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}` return `${base.slice(0, -1)} И всем в личку отправлю.`
: null,
'',
'Подтвердите или измените настройки ниже.'
]
.filter(Boolean)
.join('\n')
} }
return [ const selectedRecipients = input.members.filter((member) =>
'Schedule this notification?', input.payload.dmRecipientMemberIds.includes(member.id)
'', )
`Reminder text: ${input.payload.renderedNotificationText}`, const suffix =
`When: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, selectedRecipients.length > 0
`Precision: ${input.payload.timePrecision === 'date_only_defaulted' ? 'inferred/defaulted time' : 'exact time'}`, ? ` И выбранным в личку отправлю: ${selectedRecipients.map((member) => member.displayName).join(', ')}.`
`Delivery: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, : ' И выбранным в личку отправлю.'
assignee ? `Assignee: ${assignee.displayName}` : null, return `${base.slice(0, -1)}${suffix}`
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 }
? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
: null, const base = `Okay, Ill remind ${formatReminderWhen({
'', locale: input.locale,
'Confirm or adjust below.' scheduledForIso: input.payload.scheduledForIso,
] timezone: input.payload.timezone
.filter(Boolean) })}.`
.join('\n') if (input.payload.deliveryMode === 'topic') {
return base
}
if (input.payload.deliveryMode === 'dm_all') {
return `${base.slice(0, -1)} and DM everyone too.`
}
const selectedRecipients = input.members.filter((member) =>
input.payload.dmRecipientMemberIds.includes(member.id)
)
const suffix =
selectedRecipients.length > 0
? ` and DM the selected people too: ${selectedRecipients.map((member) => member.displayName).join(', ')}.`
: ' and DM the selected people too.'
return `${base.slice(0, -1)}${suffix}`
} }
function notificationDraftReplyMarkup( function notificationDraftReplyMarkup(
@@ -216,6 +294,27 @@ function notificationDraftReplyMarkup(
payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>, payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>,
members: readonly HouseholdMemberRecord[] members: readonly HouseholdMemberRecord[]
): InlineKeyboardMarkup { ): InlineKeyboardMarkup {
if (payload.viewMode === 'compact') {
return {
inline_keyboard: [
[
{
text: locale === 'ru' ? 'Подтвердить' : 'Confirm',
callback_data: `${AD_HOC_NOTIFICATION_CONFIRM_PREFIX}${payload.proposalId}`
},
{
text: locale === 'ru' ? 'Отменить' : 'Cancel',
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}`
},
{
text: locale === 'ru' ? 'Еще' : 'More',
callback_data: `${AD_HOC_NOTIFICATION_VIEW_PREFIX}${payload.proposalId}:expanded`
}
]
]
}
}
const deliveryButtons = [ const deliveryButtons = [
{ {
text: `${payload.deliveryMode === 'topic' ? '• ' : ''}${locale === 'ru' ? 'В топик' : 'Topic'}`, text: `${payload.deliveryMode === 'topic' ? '• ' : ''}${locale === 'ru' ? 'В топик' : 'Topic'}`,
@@ -236,6 +335,10 @@ function notificationDraftReplyMarkup(
{ {
text: locale === 'ru' ? 'Отменить' : 'Cancel', text: locale === 'ru' ? 'Отменить' : 'Cancel',
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}` callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}`
},
{
text: locale === 'ru' ? 'Скрыть' : 'Less',
callback_data: `${AD_HOC_NOTIFICATION_VIEW_PREFIX}${payload.proposalId}:compact`
} }
], ],
deliveryButtons, deliveryButtons,
@@ -416,6 +519,19 @@ async function loadDraft(
: null : null
} }
function draftLocalSchedule(payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>): {
date: string
hour: number
minute: number
} {
const zdt = Temporal.Instant.from(payload.scheduledForIso).toZonedDateTimeISO(payload.timezone)
return {
date: zdt.toPlainDate().toString(),
hour: zdt.hour,
minute: zdt.minute
}
}
export function registerAdHocNotifications(options: { export function registerAdHocNotifications(options: {
bot: Bot bot: Bot
householdConfigurationRepository: HouseholdConfigurationRepository householdConfigurationRepository: HouseholdConfigurationRepository
@@ -643,14 +759,138 @@ export function registerAdHocNotifications(options: {
stage: 'confirm', stage: 'confirm',
renderedNotificationText, renderedNotificationText,
scheduledForIso: schedule.scheduledFor!.toString(), scheduledForIso: schedule.scheduledFor!.toString(),
timePrecision: schedule.timePrecision! timePrecision: schedule.timePrecision!,
viewMode: 'compact'
} }
await saveDraft(options.promptRepository, ctx, confirmPayload) await saveDraft(options.promptRepository, ctx, confirmPayload)
await showDraftConfirmation(ctx, confirmPayload) await showDraftConfirmation(ctx, confirmPayload)
return return
} }
await next() 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
}
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({
timezone: existingDraft.timezone,
resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date,
resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour,
resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute,
resolutionMode: interpretedEdit.resolutionMode ?? 'exact'
})
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 saveDraft(options.promptRepository, ctx, nextPayload)
await showDraftConfirmation(ctx, nextPayload)
return return
} }
@@ -771,7 +1011,8 @@ export function registerAdHocNotifications(options: {
scheduledForIso: parsedSchedule.scheduledFor!.toString(), scheduledForIso: parsedSchedule.scheduledFor!.toString(),
timePrecision: parsedSchedule.timePrecision!, timePrecision: parsedSchedule.timePrecision!,
deliveryMode: 'topic', deliveryMode: 'topic',
dmRecipientMemberIds: [] dmRecipientMemberIds: [],
viewMode: 'compact'
} }
await saveDraft(options.promptRepository, ctx, draft) await saveDraft(options.promptRepository, ctx, draft)
@@ -839,16 +1080,11 @@ export function registerAdHocNotifications(options: {
reminderContext.locale === 'ru' ? 'Напоминание запланировано.' : 'Notification scheduled.' reminderContext.locale === 'ru' ? 'Напоминание запланировано.' : 'Notification scheduled.'
}) })
await ctx.editMessageText( await ctx.editMessageText(
[ notificationSummaryText({
reminderContext.locale === 'ru' locale: reminderContext.locale,
? `Напоминание запланировано: ${result.notification.notificationText}` payload,
: `Notification scheduled: ${result.notification.notificationText}`, members: reminderContext.members
formatScheduledFor( }),
reminderContext.locale,
result.notification.scheduledFor.toString(),
result.notification.timezone
)
].join('\n'),
{ {
reply_markup: buildSavedNotificationReplyMarkup( reply_markup: buildSavedNotificationReplyMarkup(
reminderContext.locale, reminderContext.locale,
@@ -898,7 +1134,8 @@ export function registerAdHocNotifications(options: {
const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = { const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
...payload, ...payload,
deliveryMode: mode, deliveryMode: mode,
dmRecipientMemberIds: mode === 'dm_selected' ? payload.dmRecipientMemberIds : [] dmRecipientMemberIds: mode === 'dm_selected' ? payload.dmRecipientMemberIds : [],
viewMode: 'expanded'
} }
await refreshConfirmationMessage(ctx, nextPayload) await refreshConfirmationMessage(ctx, nextPayload)
await ctx.answerCallbackQuery() await ctx.answerCallbackQuery()
@@ -930,7 +1167,29 @@ export function registerAdHocNotifications(options: {
await refreshConfirmationMessage(ctx, { await refreshConfirmationMessage(ctx, {
...payload, ...payload,
dmRecipientMemberIds: [...selected] dmRecipientMemberIds: [...selected],
viewMode: 'expanded'
})
await ctx.answerCallbackQuery()
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_VIEW_PREFIX)) {
const [proposalId, viewMode] = data.slice(AD_HOC_NOTIFICATION_VIEW_PREFIX.length).split(':')
const payload = await loadDraft(options.promptRepository, ctx)
if (
!payload ||
payload.stage !== 'confirm' ||
payload.proposalId !== proposalId ||
(viewMode !== 'compact' && viewMode !== 'expanded')
) {
await next()
return
}
await refreshConfirmationMessage(ctx, {
...payload,
viewMode
}) })
await ctx.answerCallbackQuery() await ctx.answerCallbackQuery()
return return

View File

@@ -206,6 +206,65 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
} }
}) })
test('interprets draft edits as partial updates', async () => {
const interpreter = createOpenAiAdHocNotificationInterpreter({
apiKey: 'test-key',
parserModel: 'gpt-5-mini',
rendererModel: 'gpt-5-mini',
timeoutMs: 5000
})
expect(interpreter).toBeDefined()
const originalFetch = globalThis.fetch
globalThis.fetch = (async () =>
nestedJsonResponse({
decision: 'updated',
notificationText: null,
assigneeChanged: false,
assigneeMemberId: null,
resolvedLocalDate: '2026-03-24',
resolvedHour: 10,
resolvedMinute: 0,
resolutionMode: 'exact',
deliveryMode: null,
dmRecipientMemberIds: null,
confidence: 88,
clarificationQuestion: null
})) as unknown as typeof fetch
try {
const result = await interpreter!.interpretDraftEdit({
locale: 'ru',
timezone: 'Asia/Tbilisi',
localNow: '2026-03-23 23:30',
text: 'Давай на 10 часов лучше',
members: [
{ memberId: 'dima', displayName: 'Дима', status: 'active' },
{ memberId: 'georgiy', displayName: 'Георгий', status: 'active' }
],
senderMemberId: 'dima',
currentNotificationText: 'пошпынять Георгия о том, позвонил ли он',
currentAssigneeMemberId: 'georgiy',
currentScheduledLocalDate: '2026-03-24',
currentScheduledHour: 9,
currentScheduledMinute: 0,
currentDeliveryMode: 'topic',
currentDmRecipientMemberIds: []
})
expect(result).toMatchObject({
decision: 'updated',
resolvedHour: 10,
resolvedMinute: 0,
resolutionMode: 'exact',
notificationText: null,
deliveryMode: null
})
} finally {
globalThis.fetch = originalFetch
}
})
test('renders the final delivery text that should be persisted', async () => { test('renders the final delivery text that should be persisted', async () => {
const interpreter = createOpenAiAdHocNotificationInterpreter({ const interpreter = createOpenAiAdHocNotificationInterpreter({
apiKey: 'test-key', apiKey: 'test-key',

View File

@@ -1,3 +1,5 @@
import type { AdHocNotificationDeliveryMode } from '@household/ports'
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses' import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
export type AdHocNotificationResolutionMode = 'exact' | 'fuzzy_window' | 'date_only' | 'ambiguous' export type AdHocNotificationResolutionMode = 'exact' | 'fuzzy_window' | 'date_only' | 'ambiguous'
@@ -32,6 +34,22 @@ export interface AdHocNotificationScheduleInterpretation {
parserMode: 'llm' parserMode: 'llm'
} }
export interface AdHocNotificationDraftEditInterpretation {
decision: 'updated' | 'clarification'
notificationText: string | null
assigneeChanged: boolean
assigneeMemberId: string | null
resolvedLocalDate: string | null
resolvedHour: number | null
resolvedMinute: number | null
resolutionMode: AdHocNotificationResolutionMode | null
deliveryMode: AdHocNotificationDeliveryMode | null
dmRecipientMemberIds: readonly string[] | null
clarificationQuestion: string | null
confidence: number
parserMode: 'llm'
}
interface ReminderInterpretationResult { interface ReminderInterpretationResult {
decision: 'notification' | 'clarification' | 'not_notification' decision: 'notification' | 'clarification' | 'not_notification'
notificationText: string | null notificationText: string | null
@@ -58,6 +76,21 @@ interface ReminderDeliveryTextResult {
text: string | null text: string | null
} }
interface ReminderDraftEditResult {
decision: 'updated' | 'clarification'
notificationText: string | null
assigneeChanged: boolean
assigneeMemberId: string | null
resolvedLocalDate: string | null
resolvedHour: number | null
resolvedMinute: number | null
resolutionMode: AdHocNotificationResolutionMode | null
deliveryMode: AdHocNotificationDeliveryMode | null
dmRecipientMemberIds: string[] | null
confidence: number
clarificationQuestion: string | null
}
export interface AdHocNotificationInterpreter { export interface AdHocNotificationInterpreter {
interpretRequest(input: { interpretRequest(input: {
locale: 'en' | 'ru' locale: 'en' | 'ru'
@@ -75,6 +108,23 @@ export interface AdHocNotificationInterpreter {
localNow: string localNow: string
text: string text: string
}): Promise<AdHocNotificationScheduleInterpretation | null> }): Promise<AdHocNotificationScheduleInterpretation | null>
interpretDraftEdit(input: {
locale: 'en' | 'ru'
timezone: string
localNow: string
text: string
members: readonly AdHocNotificationInterpreterMember[]
senderMemberId: string
currentNotificationText: string
currentAssigneeMemberId: string | null
currentScheduledLocalDate: string
currentScheduledHour: number
currentScheduledMinute: number
currentDeliveryMode: AdHocNotificationDeliveryMode
currentDmRecipientMemberIds: readonly string[]
assistantContext?: string | null
assistantTone?: string | null
}): Promise<AdHocNotificationDraftEditInterpretation | null>
renderDeliveryText(input: { renderDeliveryText(input: {
locale: 'en' | 'ru' locale: 'en' | 'ru'
originalRequestText: string originalRequestText: string
@@ -86,6 +136,25 @@ export interface AdHocNotificationInterpreter {
}): Promise<string | null> }): Promise<string | null>
} }
function normalizeDeliveryMode(
value: string | null | undefined
): AdHocNotificationDeliveryMode | null {
return value === 'topic' || value === 'dm_all' || value === 'dm_selected' ? value : null
}
function normalizeMemberIds(
value: readonly string[] | null | undefined,
members: readonly AdHocNotificationInterpreterMember[]
): readonly string[] | null {
if (value === null || value === undefined) {
return null
}
const valid = new Set(members.map((member) => member.memberId))
const selected = value.filter((memberId) => valid.has(memberId))
return [...new Set(selected)]
}
function normalizeOptionalText(value: string | null | undefined): string | null { function normalizeOptionalText(value: string | null | undefined): string | null {
const trimmed = value?.trim() const trimmed = value?.trim()
return trimmed && trimmed.length > 0 ? trimmed : null return trimmed && trimmed.length > 0 ? trimmed : null
@@ -440,6 +509,143 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
} }
}, },
async interpretDraftEdit(options) {
const parsed = await fetchStructuredResult<ReminderDraftEditResult>({
apiKey,
model: parserModel,
schemaName: 'ad_hoc_notification_draft_edit',
schema: {
type: 'object',
additionalProperties: false,
properties: {
decision: {
type: 'string',
enum: ['updated', 'clarification']
},
notificationText: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
assigneeChanged: {
type: 'boolean'
},
assigneeMemberId: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
resolvedLocalDate: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
resolvedHour: {
anyOf: [{ type: 'integer' }, { type: 'null' }]
},
resolvedMinute: {
anyOf: [{ type: 'integer' }, { type: 'null' }]
},
resolutionMode: {
anyOf: [
{
type: 'string',
enum: ['exact', 'fuzzy_window', 'date_only', 'ambiguous']
},
{ type: 'null' }
]
},
deliveryMode: {
anyOf: [
{
type: 'string',
enum: ['topic', 'dm_all', 'dm_selected']
},
{ type: 'null' }
]
},
dmRecipientMemberIds: {
anyOf: [
{
type: 'array',
items: {
type: 'string'
}
},
{ type: 'null' }
]
},
confidence: {
type: 'number',
minimum: 0,
maximum: 100
},
clarificationQuestion: {
anyOf: [{ type: 'string' }, { type: 'null' }]
}
},
required: [
'decision',
'notificationText',
'assigneeChanged',
'assigneeMemberId',
'resolvedLocalDate',
'resolvedHour',
'resolvedMinute',
'resolutionMode',
'deliveryMode',
'dmRecipientMemberIds',
'confidence',
'clarificationQuestion'
]
},
prompt: [
'You interpret edit messages for an already prepared household reminder draft.',
'Treat the latest message as a request to modify the existing draft, not as a brand new reminder.',
'Only return fields that should change; keep unchanged fields as null, except assigneeChanged must explicitly say whether the assignee should change.',
'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.',
'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.',
promptWindowRules(),
options.assistantContext ? `Household context: ${options.assistantContext}` : null,
options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null,
`Household timezone: ${options.timezone}`,
`Current local date/time in that timezone: ${options.localNow}`,
rosterText(options.members, options.senderMemberId),
'',
'Current draft:',
`- notificationText: ${options.currentNotificationText}`,
`- assigneeMemberId: ${options.currentAssigneeMemberId ?? 'none'}`,
`- scheduledLocalDate: ${options.currentScheduledLocalDate}`,
`- scheduledLocalTime: ${String(options.currentScheduledHour).padStart(2, '0')}:${String(options.currentScheduledMinute).padStart(2, '0')}`,
`- deliveryMode: ${options.currentDeliveryMode}`,
`- dmRecipientMemberIds: ${options.currentDmRecipientMemberIds.join(', ') || 'none'}`,
'',
'Latest user edit message:',
options.text
]
.filter(Boolean)
.join('\n'),
timeoutMs
})
if (!parsed) {
return null
}
return {
decision: parsed.decision === 'updated' ? 'updated' : 'clarification',
notificationText: normalizeOptionalText(parsed.notificationText),
assigneeChanged: parsed.assigneeChanged,
assigneeMemberId: normalizeMemberId(parsed.assigneeMemberId, options.members),
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
resolvedHour: normalizeHour(parsed.resolvedHour),
resolvedMinute: normalizeMinute(parsed.resolvedMinute),
resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
deliveryMode: normalizeDeliveryMode(parsed.deliveryMode),
dmRecipientMemberIds: normalizeMemberIds(parsed.dmRecipientMemberIds, options.members),
clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion),
confidence: normalizeConfidence(parsed.confidence),
parserMode: 'llm'
}
},
async renderDeliveryText(options) { async renderDeliveryText(options) {
const parsed = await fetchStructuredResult<ReminderDeliveryTextResult>({ const parsed = await fetchStructuredResult<ReminderDeliveryTextResult>({
apiKey, apiKey,