fix(bot): refine reminder scheduling and topic routing

This commit is contained in:
2026-03-24 15:04:42 +04:00
parent 63e299134d
commit a1acec5e60
9 changed files with 501 additions and 144 deletions

View File

@@ -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')
})
})

View File

@@ -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',

View File

@@ -16,34 +16,38 @@ import { formatReminderWhen, registerAdHocNotifications } from './ad-hoc-notific
import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter'
function createPromptRepository(): TelegramPendingActionRepository {
let pending: TelegramPendingActionRecord | null = null
const pending = new Map<string, TelegramPendingActionRecord>()
return {
async upsertPendingAction(input) {
pending = input
pending.set(`${input.telegramChatId}:${input.telegramUserId}`, input)
return input
},
async getPendingAction() {
return pending
async getPendingAction(telegramChatId, telegramUserId) {
return pending.get(`${telegramChatId}:${telegramUserId}`) ?? null
},
async clearPendingAction() {
pending = null
async clearPendingAction(telegramChatId, telegramUserId) {
pending.delete(`${telegramChatId}:${telegramUserId}`)
},
async clearPendingActionsForChat(telegramChatId, action) {
if (!pending || pending.telegramChatId !== telegramChatId) {
return
for (const [key, value] of pending.entries()) {
if (value.telegramChatId !== telegramChatId) {
continue
}
if (action && value.action !== action) {
continue
}
pending.delete(key)
}
if (action && pending.action !== action) {
return
}
pending = null
}
}
}
function reminderMessageUpdate(text: string, threadId = 777) {
function reminderMessageUpdate(
text: string,
threadId = 777,
from: { id: number; firstName: string } = { id: 10002, firstName: 'Dima' }
) {
return {
update_id: 4001,
message: {
@@ -56,9 +60,9 @@ function reminderMessageUpdate(text: string, threadId = 777) {
type: 'supergroup'
},
from: {
id: 10002,
id: from.id,
is_bot: false,
first_name: 'Dima'
first_name: from.firstName
},
text
}
@@ -110,6 +114,7 @@ function member(
function createHouseholdRepository() {
const members = [
member({ id: 'dima', telegramUserId: '10002', displayName: 'Дима' }),
member({ id: 'stas', telegramUserId: '10003', displayName: 'Стас' }),
member({ id: 'georgiy', displayName: 'Георгий' })
]
const settings: HouseholdBillingSettingsRecord = {
@@ -238,6 +243,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow,
resolvedHour: 9,
resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window',
clarificationQuestion: null,
confidence: 90,
@@ -250,6 +257,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow,
resolvedHour: 9,
resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window',
clarificationQuestion: null,
confidence: 90,
@@ -267,6 +276,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: null,
resolvedHour: null,
resolvedMinute: null,
relativeOffsetMinutes: null,
dateReferenceMode: null,
resolutionMode: null,
deliveryMode: null,
dmRecipientMemberIds: null,
@@ -284,6 +295,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow,
resolvedHour: 10,
resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact',
deliveryMode: null,
dmRecipientMemberIds: null,
@@ -573,6 +586,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow,
resolvedHour: 9,
resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window',
clarificationQuestion: null,
confidence: 90,
@@ -610,6 +625,148 @@ describe('registerAdHocNotifications', () => {
expect(expandedPayload.reply_markup?.inline_keyboard[0]?.[2]?.text).toBe('Скрыть')
expect(expandedPayload.reply_markup?.inline_keyboard[1]?.[0]?.text).toContain('В топик')
})
test('supports relative duration reminders like in 30 minutes', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
const scheduledRequests: Array<{ scheduledFor: string }> = []
const now = Temporal.Instant.from('2026-03-24T08:00:00Z')
const promptRepository = createPromptRepository()
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -10012345,
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const originalNow = Temporal.Now.instant
Temporal.Now.instant = () => now
try {
registerAdHocNotifications({
bot,
householdConfigurationRepository: createHouseholdRepository() as never,
promptRepository,
notificationService: {
async scheduleNotification(input) {
scheduledRequests.push({
scheduledFor: input.scheduledFor.toString()
})
return {
status: 'scheduled',
notification: {
id: 'notif-1',
householdId: input.householdId,
creatorMemberId: input.creatorMemberId,
assigneeMemberId: input.assigneeMemberId ?? null,
originalRequestText: input.originalRequestText,
notificationText: input.notificationText,
timezone: input.timezone,
scheduledFor: input.scheduledFor,
timePrecision: input.timePrecision,
deliveryMode: input.deliveryMode,
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
friendlyTagAssignee: false,
status: 'scheduled',
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
sentAt: null,
cancelledAt: null,
cancelledByMemberId: null,
createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'),
updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z')
}
}
},
async listUpcomingNotifications() {
return []
},
async cancelNotification() {
return { status: 'not_found' }
},
async updateNotification() {
return { status: 'not_found' }
},
async listDueNotifications() {
return []
},
async claimDueNotification() {
return false
},
async releaseDueNotification() {},
async markNotificationSent() {
return null
}
},
reminderInterpreter: {
async interpretRequest() {
return {
decision: 'notification',
notificationText: 'починить тебя',
assigneeMemberId: null,
resolvedLocalDate: null,
resolvedHour: null,
resolvedMinute: null,
relativeOffsetMinutes: 30,
dateReferenceMode: null,
resolutionMode: 'exact',
clarificationQuestion: null,
confidence: 94,
parserMode: 'llm'
}
},
async interpretSchedule() {
throw new Error('not used')
},
async interpretDraftEdit() {
throw new Error('not used')
},
async renderDeliveryText() {
return 'Пора чинить бота.'
}
}
})
await bot.handleUpdate(reminderMessageUpdate('Напомни починить тебя через 30 минут') as never)
const pending = await promptRepository.getPendingAction('-10012345', '10002')
const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId
expect(proposalId).toBeTruthy()
await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never)
} finally {
Temporal.Now.instant = originalNow
}
expect((calls[0]?.payload as { text?: string })?.text).toContain('сегодня в 12:30')
expect(scheduledRequests).toEqual([
{
scheduledFor: '2026-03-24T08:30:00Z'
}
])
})
})
describe('formatReminderWhen', () => {

View File

@@ -775,6 +775,8 @@ export function registerAdHocNotifications(options: {
resolvedLocalDate: interpretedSchedule.resolvedLocalDate,
resolvedHour: interpretedSchedule.resolvedHour,
resolvedMinute: interpretedSchedule.resolvedMinute,
relativeOffsetMinutes: interpretedSchedule.relativeOffsetMinutes,
dateReferenceMode: interpretedSchedule.dateReferenceMode,
resolutionMode: interpretedSchedule.resolutionMode
})
@@ -822,139 +824,148 @@ export function registerAdHocNotifications(options: {
return
}
if (!options.reminderInterpreter) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
return
}
if (existingDraft.stage === 'confirm') {
if (!options.reminderInterpreter) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
return
}
const currentSchedule = draftLocalSchedule(existingDraft)
const interpretedEdit = await options.reminderInterpreter.interpretDraftEdit({
locale: reminderContext.locale,
timezone: existingDraft.timezone,
localNow: localNowText(existingDraft.timezone),
text: messageText,
members: interpreterMembers(reminderContext.members),
senderMemberId: reminderContext.member.id,
currentNotificationText: existingDraft.normalizedNotificationText,
currentAssigneeMemberId: existingDraft.assigneeMemberId,
currentScheduledLocalDate: currentSchedule.date,
currentScheduledHour: currentSchedule.hour,
currentScheduledMinute: currentSchedule.minute,
currentDeliveryMode: existingDraft.deliveryMode,
currentDmRecipientMemberIds: existingDraft.dmRecipientMemberIds,
assistantContext: reminderContext.assistantContext,
assistantTone: reminderContext.assistantTone
})
if (!interpretedEdit) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
return
}
if (interpretedEdit.decision === 'clarification') {
await replyInTopic(
ctx,
interpretedEdit.clarificationQuestion ??
(reminderContext.locale === 'ru'
? 'Что именно поправить в напоминании?'
: 'What should I adjust in the reminder?')
)
return
}
if (interpretedEdit.decision === 'cancel') {
await options.promptRepository.clearPendingAction(
ctx.chat!.id.toString(),
ctx.from!.id.toString()
)
await replyInTopic(ctx, cancelledDraftReply(reminderContext.locale))
return
}
const scheduleChanged =
interpretedEdit.resolvedLocalDate !== null ||
interpretedEdit.resolvedHour !== null ||
interpretedEdit.resolvedMinute !== null ||
interpretedEdit.resolutionMode !== null
let nextSchedule = {
scheduledForIso: existingDraft.scheduledForIso,
timePrecision: existingDraft.timePrecision
}
if (scheduleChanged) {
const parsedSchedule = parseAdHocNotificationSchedule({
const currentSchedule = draftLocalSchedule(existingDraft)
const interpretedEdit = await options.reminderInterpreter.interpretDraftEdit({
locale: reminderContext.locale,
timezone: existingDraft.timezone,
resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date,
resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour,
resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute,
resolutionMode: interpretedEdit.resolutionMode ?? 'exact'
localNow: localNowText(existingDraft.timezone),
text: messageText,
members: interpreterMembers(reminderContext.members),
senderMemberId: reminderContext.member.id,
currentNotificationText: existingDraft.normalizedNotificationText,
currentAssigneeMemberId: existingDraft.assigneeMemberId,
currentScheduledLocalDate: currentSchedule.date,
currentScheduledHour: currentSchedule.hour,
currentScheduledMinute: currentSchedule.minute,
currentDeliveryMode: existingDraft.deliveryMode,
currentDmRecipientMemberIds: existingDraft.dmRecipientMemberIds,
assistantContext: reminderContext.assistantContext,
assistantTone: reminderContext.assistantTone
})
if (parsedSchedule.kind === 'missing_schedule') {
if (!interpretedEdit) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
return
}
if (interpretedEdit.decision === 'clarification') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Нужны понятные дата или время, чтобы обновить напоминание.'
: 'I need a clear date or time to update the reminder.'
interpretedEdit.clarificationQuestion ??
(reminderContext.locale === 'ru'
? 'Что именно поправить в напоминании?'
: 'What should I adjust in the reminder?')
)
return
}
if (parsedSchedule.kind === 'invalid_past') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Это время уже в прошлом. Пришлите будущую дату или время.'
: 'That time is already in the past. Send a future date or time.'
if (interpretedEdit.decision === 'cancel') {
await options.promptRepository.clearPendingAction(
ctx.chat!.id.toString(),
ctx.from!.id.toString()
)
await replyInTopic(ctx, cancelledDraftReply(reminderContext.locale))
return
}
nextSchedule = {
scheduledForIso: parsedSchedule.scheduledFor!.toString(),
timePrecision: parsedSchedule.timePrecision!
const scheduleChanged =
interpretedEdit.resolvedLocalDate !== null ||
interpretedEdit.resolvedHour !== null ||
interpretedEdit.resolvedMinute !== null ||
interpretedEdit.relativeOffsetMinutes !== null ||
interpretedEdit.resolutionMode !== null
let nextSchedule = {
scheduledForIso: existingDraft.scheduledForIso,
timePrecision: existingDraft.timePrecision
}
}
const nextNormalizedNotificationText =
interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText
const nextOriginalRequestText =
interpretedEdit.notificationText !== null ? messageText : existingDraft.originalRequestText
const nextAssigneeMemberId = interpretedEdit.assigneeChanged
? interpretedEdit.assigneeMemberId
: existingDraft.assigneeMemberId
const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode
const nextDmRecipientMemberIds =
interpretedEdit.dmRecipientMemberIds ??
(nextDeliveryMode === existingDraft.deliveryMode ? existingDraft.dmRecipientMemberIds : [])
if (scheduleChanged) {
const parsedSchedule = parseAdHocNotificationSchedule({
timezone: existingDraft.timezone,
resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date,
resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour,
resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute,
relativeOffsetMinutes: interpretedEdit.relativeOffsetMinutes,
dateReferenceMode: interpretedEdit.dateReferenceMode,
resolutionMode: interpretedEdit.resolutionMode ?? 'exact'
})
const renderedNotificationText = await renderNotificationText({
reminderContext,
originalRequestText: nextOriginalRequestText,
normalizedNotificationText: nextNormalizedNotificationText,
assigneeMemberId: nextAssigneeMemberId
})
if (!renderedNotificationText) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
if (parsedSchedule.kind === 'missing_schedule') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Нужны понятные дата или время, чтобы обновить напоминание.'
: 'I need a clear date or time to update the reminder.'
)
return
}
if (parsedSchedule.kind === 'invalid_past') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Это время уже в прошлом. Пришлите будущую дату или время.'
: 'That time is already in the past. Send a future date or time.'
)
return
}
nextSchedule = {
scheduledForIso: parsedSchedule.scheduledFor!.toString(),
timePrecision: parsedSchedule.timePrecision!
}
}
const nextNormalizedNotificationText =
interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText
const nextOriginalRequestText =
interpretedEdit.notificationText !== null
? messageText
: existingDraft.originalRequestText
const nextAssigneeMemberId = interpretedEdit.assigneeChanged
? interpretedEdit.assigneeMemberId
: existingDraft.assigneeMemberId
const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode
const nextDmRecipientMemberIds =
interpretedEdit.dmRecipientMemberIds ??
(nextDeliveryMode === existingDraft.deliveryMode
? existingDraft.dmRecipientMemberIds
: [])
const renderedNotificationText = await renderNotificationText({
reminderContext,
originalRequestText: nextOriginalRequestText,
normalizedNotificationText: nextNormalizedNotificationText,
assigneeMemberId: nextAssigneeMemberId
})
if (!renderedNotificationText) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
return
}
const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
...existingDraft,
originalRequestText: nextOriginalRequestText,
normalizedNotificationText: nextNormalizedNotificationText,
renderedNotificationText,
assigneeMemberId: nextAssigneeMemberId,
scheduledForIso: nextSchedule.scheduledForIso,
timePrecision: nextSchedule.timePrecision,
deliveryMode: nextDeliveryMode,
dmRecipientMemberIds: nextDmRecipientMemberIds,
viewMode: 'compact'
}
await showDraftConfirmation(ctx, nextPayload, existingDraft.confirmationMessageId)
return
}
const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
...existingDraft,
originalRequestText: nextOriginalRequestText,
normalizedNotificationText: nextNormalizedNotificationText,
renderedNotificationText,
assigneeMemberId: nextAssigneeMemberId,
scheduledForIso: nextSchedule.scheduledForIso,
timePrecision: nextSchedule.timePrecision,
deliveryMode: nextDeliveryMode,
dmRecipientMemberIds: nextDmRecipientMemberIds,
viewMode: 'compact'
}
await showDraftConfirmation(ctx, nextPayload, existingDraft.confirmationMessageId)
return
}
if (!options.reminderInterpreter) {
@@ -1025,6 +1036,8 @@ export function registerAdHocNotifications(options: {
resolvedLocalDate: interpretedRequest.resolvedLocalDate,
resolvedHour: interpretedRequest.resolvedHour,
resolvedMinute: interpretedRequest.resolvedMinute,
relativeOffsetMinutes: interpretedRequest.relativeOffsetMinutes,
dateReferenceMode: interpretedRequest.dateReferenceMode,
resolutionMode: interpretedRequest.resolutionMode
})
@@ -1167,6 +1180,7 @@ export function registerAdHocNotifications(options: {
const payload = await loadDraft(options.promptRepository, ctx)
if (
!payload ||
payload.stage !== 'confirm' ||
payload.proposalId !== proposalId ||
!ctx.chat ||
!ctx.from ||

View File

@@ -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

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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(' ')