mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): simplify reminder confirmation flow
This commit is contained in:
@@ -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 утра')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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, I’ll 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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user