diff --git a/apps/bot/src/ad-hoc-notification-jobs.test.ts b/apps/bot/src/ad-hoc-notification-jobs.test.ts index 328efc1..93025ee 100644 --- a/apps/bot/src/ad-hoc-notification-jobs.test.ts +++ b/apps/bot/src/ad-hoc-notification-jobs.test.ts @@ -15,7 +15,7 @@ function dueNotification( creatorMemberId: input.creatorMemberId ?? 'creator', assigneeMemberId: input.assigneeMemberId ?? 'assignee', originalRequestText: 'raw', - notificationText: input.notificationText ?? 'Ping Georgiy', + notificationText: input.notificationText ?? 'Georgiy, time to call already.', timezone: input.timezone ?? 'Asia/Tbilisi', scheduledFor: input.scheduledFor ?? Temporal.Instant.from('2026-03-23T09:00:00Z'), timePrecision: input.timePrecision ?? 'exact', @@ -108,7 +108,7 @@ describe('createAdHocNotificationJobsHandler', () => { expect(payload.ok).toBe(true) expect(payload.notifications[0]?.outcome).toBe('sent') - expect(sentTopicMessages[0]).toContain('tg://user?id=222') + expect(sentTopicMessages[0]).toContain('Georgiy, time to call already.') expect(sentNotifications).toEqual(['notif-1']) }) }) diff --git a/apps/bot/src/ad-hoc-notification-jobs.ts b/apps/bot/src/ad-hoc-notification-jobs.ts index be8d77d..0a19816 100644 --- a/apps/bot/src/ad-hoc-notification-jobs.ts +++ b/apps/bot/src/ad-hoc-notification-jobs.ts @@ -70,9 +70,7 @@ export function createAdHocNotificationJobsHandler(options: { } const content = buildTopicNotificationText({ - notificationText: notification.notification.notificationText, - assignee: notification.assignee, - friendlyTagAssignee: notification.notification.friendlyTagAssignee + notificationText: notification.notification.notificationText }) await options.sendTopicMessage({ householdId: notification.notification.householdId, diff --git a/apps/bot/src/ad-hoc-notification-parser.test.ts b/apps/bot/src/ad-hoc-notification-parser.test.ts index 8c099bd..9987d4b 100644 --- a/apps/bot/src/ad-hoc-notification-parser.test.ts +++ b/apps/bot/src/ad-hoc-notification-parser.test.ts @@ -1,59 +1,32 @@ import { describe, expect, test } from 'bun:test' import { Temporal } from '@household/domain' -import type { HouseholdMemberRecord } from '@household/ports' -import { - parseAdHocNotificationRequest, - parseAdHocNotificationSchedule -} from './ad-hoc-notification-parser' +import { parseAdHocNotificationSchedule } from './ad-hoc-notification-parser' -function member( - input: Partial & Pick -): HouseholdMemberRecord { - return { - id: input.id, - householdId: input.householdId ?? 'household-1', - telegramUserId: input.telegramUserId ?? `${input.id}-tg`, - displayName: input.displayName ?? input.id, - status: input.status ?? 'active', - preferredLocale: input.preferredLocale ?? 'ru', - householdDefaultLocale: input.householdDefaultLocale ?? 'ru', - rentShareWeight: input.rentShareWeight ?? 1, - isAdmin: input.isAdmin ?? false - } -} - -describe('parseAdHocNotificationRequest', () => { - const members = [ - member({ id: 'dima', displayName: 'Дима' }), - member({ id: 'georgiy', displayName: 'Георгий' }) - ] - - test('parses exact datetime and assignee from russian request', () => { - const parsed = parseAdHocNotificationRequest({ - text: 'Железяка, напомни пошпынять Георгия завтра в 15:30', +describe('parseAdHocNotificationSchedule', () => { + test('parses exact local datetime from structured input', () => { + const parsed = parseAdHocNotificationSchedule({ timezone: 'Asia/Tbilisi', - locale: 'ru', - members, - senderMemberId: 'dima', + resolvedLocalDate: '2026-03-24', + resolvedHour: 15, + resolvedMinute: 30, + resolutionMode: 'exact', now: Temporal.Instant.from('2026-03-23T09:00:00Z') }) expect(parsed.kind).toBe('parsed') - expect(parsed.notificationText).toContain('пошпынять Георгия') - expect(parsed.assigneeMemberId).toBe('georgiy') expect(parsed.timePrecision).toBe('exact') expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T11:30:00Z') }) - test('defaults vague tomorrow to daytime slot', () => { - const parsed = parseAdHocNotificationRequest({ - text: 'напомни Георгию завтра про звонок', + test('keeps date-only schedules as inferred/defaulted', () => { + const parsed = parseAdHocNotificationSchedule({ timezone: 'Asia/Tbilisi', - locale: 'ru', - members, - senderMemberId: 'dima', + resolvedLocalDate: '2026-03-24', + resolvedHour: 12, + resolvedMinute: 0, + resolutionMode: 'date_only', now: Temporal.Instant.from('2026-03-23T09:00:00Z') }) @@ -62,26 +35,41 @@ describe('parseAdHocNotificationRequest', () => { expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T08:00:00Z') }) - test('requests follow-up when schedule is missing', () => { - const parsed = parseAdHocNotificationRequest({ - text: 'напомни пошпынять Георгия', + test('supports fuzzy-window schedules as inferred/defaulted', () => { + const parsed = parseAdHocNotificationSchedule({ timezone: 'Asia/Tbilisi', - locale: 'ru', - members, - senderMemberId: 'dima', + resolvedLocalDate: '2026-03-24', + resolvedHour: 9, + resolvedMinute: 0, + resolutionMode: 'fuzzy_window', + now: Temporal.Instant.from('2026-03-23T09:00:00Z') + }) + + expect(parsed.kind).toBe('parsed') + expect(parsed.timePrecision).toBe('date_only_defaulted') + expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T05:00:00Z') + }) + + test('treats ambiguous structured schedule as missing', () => { + const parsed = parseAdHocNotificationSchedule({ + timezone: 'Asia/Tbilisi', + resolvedLocalDate: null, + resolvedHour: null, + resolvedMinute: null, + resolutionMode: 'ambiguous', now: Temporal.Instant.from('2026-03-23T09:00:00Z') }) expect(parsed.kind).toBe('missing_schedule') - expect(parsed.notificationText).toContain('пошпынять Георгия') }) -}) -describe('parseAdHocNotificationSchedule', () => { - test('rejects past schedule', () => { + test('rejects past structured schedule', () => { const parsed = parseAdHocNotificationSchedule({ - text: 'сегодня в 10:00', timezone: 'Asia/Tbilisi', + resolvedLocalDate: '2026-03-23', + resolvedHour: 10, + resolvedMinute: 0, + resolutionMode: 'exact', now: Temporal.Instant.from('2026-03-23T09:00:00Z') }) diff --git a/apps/bot/src/ad-hoc-notification-parser.ts b/apps/bot/src/ad-hoc-notification-parser.ts index 949ead1..ce3fb04 100644 --- a/apps/bot/src/ad-hoc-notification-parser.ts +++ b/apps/bot/src/ad-hoc-notification-parser.ts @@ -1,16 +1,6 @@ import { Temporal, nowInstant, type Instant } from '@household/domain' -import type { HouseholdMemberRecord } from '@household/ports' -type SupportedLocale = 'en' | 'ru' - -export interface ParsedAdHocNotificationRequest { - kind: 'parsed' | 'missing_schedule' | 'invalid_past' | 'not_intent' - originalRequestText: string - notificationText: string | null - assigneeMemberId: string | null - scheduledFor: Instant | null - timePrecision: 'exact' | 'date_only_defaulted' | null -} +import type { AdHocNotificationResolutionMode } from './openai-ad-hoc-notification-interpreter' export interface ParsedAdHocNotificationSchedule { kind: 'parsed' | 'missing_schedule' | 'invalid_past' @@ -18,233 +8,35 @@ export interface ParsedAdHocNotificationSchedule { timePrecision: 'exact' | 'date_only_defaulted' | null } -const INTENT_PATTERNS = [ - /\bremind(?: me)?(?: to)?\b/i, - /\bping me\b/i, - /\bnotification\b/i, - /(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu, - /(?:^|[^\p{L}])напоминани[ея](?=$|[^\p{L}])/iu, - /(?:^|[^\p{L}])пни(?=$|[^\p{L}])/iu, - /(?:^|[^\p{L}])толкни(?=$|[^\p{L}])/iu -] as const - -const DAYTIME_DEFAULT_HOUR = 12 - -function normalizeWhitespace(value: string): string { - return value.replace(/\s+/gu, ' ').trim() -} - -function stripLeadingBotAddress(value: string): string { - const match = value.match(/^([^,\n:]{1,40})([:,])\s+/u) - if (!match) { - return value +function precisionFromResolutionMode( + resolutionMode: AdHocNotificationResolutionMode | null +): 'exact' | 'date_only_defaulted' | null { + if (resolutionMode === 'exact') { + return 'exact' } - return value.slice(match[0].length) -} - -function hasIntent(value: string): boolean { - return INTENT_PATTERNS.some((pattern) => pattern.test(value)) -} - -function removeIntentPreamble(value: string): string { - const normalized = stripLeadingBotAddress(value) - const patterns = [ - /\bremind(?: me)?(?: to)?\b/iu, - /\bping me to\b/iu, - /(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu, - /(?:^|[^\p{L}])пни(?=$|[^\p{L}])/iu, - /(?:^|[^\p{L}])толкни(?=$|[^\p{L}])/iu - ] - - for (const pattern of patterns) { - const match = pattern.exec(normalized) - if (!match) { - continue - } - - return normalizeWhitespace(normalized.slice(match.index + match[0].length)) - } - - return normalizeWhitespace(normalized) -} - -function aliasVariants(token: string): string[] { - const aliases = new Set([token]) - - if (token.endsWith('а') && token.length > 2) { - aliases.add(`${token.slice(0, -1)}ы`) - aliases.add(`${token.slice(0, -1)}е`) - aliases.add(`${token.slice(0, -1)}у`) - aliases.add(`${token.slice(0, -1)}ой`) - } - - if (token.endsWith('я') && token.length > 2) { - aliases.add(`${token.slice(0, -1)}и`) - aliases.add(`${token.slice(0, -1)}ю`) - aliases.add(`${token.slice(0, -1)}ей`) - } - - if (token.endsWith('й') && token.length > 2) { - aliases.add(`${token.slice(0, -1)}я`) - aliases.add(`${token.slice(0, -1)}ю`) - } - - return [...aliases] -} - -function normalizeText(value: string): string { - return value - .toLowerCase() - .replace(/[^\p{L}\p{N}\s]/gu, ' ') - .replace(/\s+/gu, ' ') - .trim() -} - -function memberAliases(member: HouseholdMemberRecord): string[] { - const normalized = normalizeText(member.displayName) - const tokens = normalized.split(' ').filter((token) => token.length >= 2) - const aliases = new Set([normalized, ...tokens]) - - for (const token of tokens) { - for (const alias of aliasVariants(token)) { - aliases.add(alias) - } - } - - return [...aliases] -} - -function detectAssignee( - text: string, - members: readonly HouseholdMemberRecord[], - senderMemberId: string -): string | null { - const normalizedText = ` ${normalizeText(text)} ` - const candidates = members - .filter((member) => member.status === 'active' && member.id !== senderMemberId) - .map((member) => ({ - memberId: member.id, - score: memberAliases(member).reduce((best, alias) => { - const normalizedAlias = alias.trim() - if (normalizedAlias.length < 2) { - return best - } - - if (normalizedText.includes(` ${normalizedAlias} `)) { - return Math.max(best, normalizedAlias.length + 10) - } - - return best - }, 0) - })) - .filter((entry) => entry.score > 0) - .sort((left, right) => right.score - left.score) - - return candidates[0]?.memberId ?? null -} - -function parseTime(text: string): { - hour: number - minute: number - matchedText: string | null -} | null { - const explicit = /(?:^|\s)(?:at|в)\s*(\d{1,2})(?::(\d{2}))?(?=$|[^\d])/iu.exec(text) - const standalone = explicit ? explicit : /(?:^|\s)(\d{1,2}):(\d{2})(?=$|[^\d])/u.exec(text) - const match = standalone - if (!match) { - return null - } - - const hour = Number(match[1]) - const minute = Number(match[2] ?? '0') - if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) { - return null - } - - return { - hour, - minute, - matchedText: match[0] - } -} - -function parseDate( - text: string, - timezone: string, - referenceInstant: Instant -): { - date: Temporal.PlainDate - matchedText: string | null - precision: 'exact' | 'date_only_defaulted' -} | null { - const localNow = referenceInstant.toZonedDateTimeISO(timezone) - - const relativePatterns: Array<{ - pattern: RegExp - days: number - }> = [ - { pattern: /\bday after tomorrow\b/iu, days: 2 }, - { pattern: /(?:^|[^\p{L}])послезавтра(?=$|[^\p{L}])/iu, days: 2 }, - { pattern: /\btomorrow\b/iu, days: 1 }, - { pattern: /(?:^|[^\p{L}])завтра(?=$|[^\p{L}])/iu, days: 1 }, - { pattern: /\btoday\b/iu, days: 0 }, - { pattern: /(?:^|[^\p{L}])сегодня(?=$|[^\p{L}])/iu, days: 0 } - ] - - for (const entry of relativePatterns) { - const match = entry.pattern.exec(text) - if (!match) { - continue - } - - return { - date: localNow.toPlainDate().add({ days: entry.days }), - matchedText: match[0], - precision: 'date_only_defaulted' - } - } - - const isoMatch = /\b(\d{4})-(\d{2})-(\d{2})\b/u.exec(text) - if (isoMatch) { - return { - date: Temporal.PlainDate.from({ - year: Number(isoMatch[1]), - month: Number(isoMatch[2]), - day: Number(isoMatch[3]) - }), - matchedText: isoMatch[0], - precision: 'date_only_defaulted' - } - } - - const dottedMatch = /\b(\d{1,2})[./](\d{1,2})(?:[./](\d{4}))?\b/u.exec(text) - if (dottedMatch) { - return { - date: Temporal.PlainDate.from({ - year: Number(dottedMatch[3] ?? String(localNow.year)), - month: Number(dottedMatch[2]), - day: Number(dottedMatch[1]) - }), - matchedText: dottedMatch[0], - precision: 'date_only_defaulted' - } + if (resolutionMode === 'fuzzy_window' || resolutionMode === 'date_only') { + return 'date_only_defaulted' } return null } export function parseAdHocNotificationSchedule(input: { - text: string timezone: string + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null now?: Instant }): ParsedAdHocNotificationSchedule { - const rawText = normalizeWhitespace(input.text) - const referenceInstant = input.now ?? nowInstant() - const date = parseDate(rawText, input.timezone, referenceInstant) - const time = parseTime(rawText) - - if (!date) { + const timePrecision = precisionFromResolutionMode(input.resolutionMode) + if ( + !input.resolvedLocalDate || + input.resolutionMode === null || + input.resolutionMode === 'ambiguous' || + timePrecision === null + ) { return { kind: 'missing_schedule', scheduledFor: null, @@ -252,119 +44,51 @@ export function parseAdHocNotificationSchedule(input: { } } - const scheduledDateTime = Temporal.ZonedDateTime.from({ - timeZone: input.timezone, - year: date.date.year, - month: date.date.month, - day: date.date.day, - hour: time?.hour ?? DAYTIME_DEFAULT_HOUR, - minute: time?.minute ?? 0, - second: 0, - millisecond: 0 - }).toInstant() + const hour = + input.resolutionMode === 'date_only' ? (input.resolvedHour ?? 12) : input.resolvedHour + const minute = + input.resolutionMode === 'date_only' ? (input.resolvedMinute ?? 0) : input.resolvedMinute - if (scheduledDateTime.epochMilliseconds <= referenceInstant.epochMilliseconds) { - return { - kind: 'invalid_past', - scheduledFor: scheduledDateTime, - timePrecision: time ? 'exact' : 'date_only_defaulted' - } - } - - return { - kind: 'parsed', - scheduledFor: scheduledDateTime, - timePrecision: time ? 'exact' : 'date_only_defaulted' - } -} - -function stripScheduleFragments(text: string, fragments: readonly (string | null)[]): string { - let next = text - - for (const fragment of fragments) { - if (!fragment || fragment.trim().length === 0) { - continue - } - - next = next.replace(fragment, ' ') - } - - next = next - .replace(/\b(?:on|at|в)\b/giu, ' ') - .replace(/\s+/gu, ' ') - .trim() - .replace(/^[,.\-:;]+/u, '') - .replace(/[,\-:;]+$/u, '') - .trim() - - return next -} - -export function parseAdHocNotificationRequest(input: { - text: string - timezone: string - locale: SupportedLocale - members: readonly HouseholdMemberRecord[] - senderMemberId: string - now?: Instant -}): ParsedAdHocNotificationRequest { - const rawText = normalizeWhitespace(input.text) - if (!hasIntent(rawText)) { - return { - kind: 'not_intent', - originalRequestText: rawText, - notificationText: null, - assigneeMemberId: null, - scheduledFor: null, - timePrecision: null - } - } - - const body = removeIntentPreamble(rawText) - const referenceInstant = input.now ?? nowInstant() - const date = parseDate(body, input.timezone, referenceInstant) - const time = parseTime(body) - - const notificationText = stripScheduleFragments(body, [ - date?.matchedText ?? null, - time?.matchedText ?? null - ]) - const assigneeMemberId = detectAssignee(notificationText, input.members, input.senderMemberId) - - if (!date) { + if (hour === null || minute === null) { return { kind: 'missing_schedule', - originalRequestText: rawText, - notificationText: notificationText.length > 0 ? notificationText : body, - assigneeMemberId, scheduledFor: null, timePrecision: null } } - const schedule = parseAdHocNotificationSchedule({ - text: [date.matchedText, time?.matchedText].filter(Boolean).join(' '), - timezone: input.timezone, - now: referenceInstant - }) + try { + const date = Temporal.PlainDate.from(input.resolvedLocalDate) + const scheduled = Temporal.ZonedDateTime.from({ + timeZone: input.timezone, + year: date.year, + month: date.month, + day: date.day, + hour, + minute, + second: 0, + millisecond: 0 + }).toInstant() + + const effectiveNow = input.now ?? nowInstant() + if (scheduled.epochMilliseconds <= effectiveNow.epochMilliseconds) { + return { + kind: 'invalid_past', + scheduledFor: null, + timePrecision: null + } + } - if (schedule.kind === 'invalid_past') { return { - kind: 'invalid_past', - originalRequestText: rawText, - notificationText: notificationText.length > 0 ? notificationText : body, - assigneeMemberId, - scheduledFor: schedule.scheduledFor, - timePrecision: schedule.timePrecision + kind: 'parsed', + scheduledFor: scheduled, + timePrecision + } + } catch { + return { + kind: 'missing_schedule', + scheduledFor: null, + timePrecision: null } } - - return { - kind: 'parsed', - originalRequestText: rawText, - notificationText: notificationText.length > 0 ? notificationText : body, - assigneeMemberId, - scheduledFor: schedule.scheduledFor, - timePrecision: schedule.timePrecision - } } diff --git a/apps/bot/src/ad-hoc-notifications.test.ts b/apps/bot/src/ad-hoc-notifications.test.ts new file mode 100644 index 0000000..3a66993 --- /dev/null +++ b/apps/bot/src/ad-hoc-notifications.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from 'bun:test' + +import type { AdHocNotificationService } from '@household/application' +import { Temporal } from '@household/domain' +import type { + HouseholdAssistantConfigRecord, + HouseholdBillingSettingsRecord, + HouseholdMemberRecord, + TelegramPendingActionRecord, + TelegramPendingActionRepository +} from '@household/ports' + +import { createTelegramBot } from './bot' +import { registerAdHocNotifications } from './ad-hoc-notifications' +import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' + +function createPromptRepository(): TelegramPendingActionRepository { + let pending: TelegramPendingActionRecord | null = null + + return { + async upsertPendingAction(input) { + pending = input + return input + }, + async getPendingAction() { + return pending + }, + async clearPendingAction() { + pending = null + }, + async clearPendingActionsForChat(telegramChatId, action) { + if (!pending || pending.telegramChatId !== telegramChatId) { + return + } + + if (action && pending.action !== action) { + return + } + + pending = null + } + } +} + +function reminderMessageUpdate(text: string, threadId = 777) { + return { + update_id: 4001, + message: { + message_id: 55, + date: Math.floor(Date.now() / 1000), + message_thread_id: threadId, + is_topic_message: true, + chat: { + id: -10012345, + type: 'supergroup' + }, + from: { + id: 10002, + is_bot: false, + first_name: 'Dima' + }, + text + } + } +} + +function reminderCallbackUpdate(data: string, threadId = 777) { + return { + update_id: 4002, + callback_query: { + id: 'callback-adhoc-1', + from: { + id: 10002, + is_bot: false, + first_name: 'Dima' + }, + chat_instance: 'instance-1', + data, + message: { + message_id: 99, + date: Math.floor(Date.now() / 1000), + message_thread_id: threadId, + chat: { + id: -10012345, + type: 'supergroup' + }, + text: 'placeholder' + } + } + } +} + +function member( + input: Partial & Pick +): HouseholdMemberRecord { + return { + id: input.id, + householdId: input.householdId ?? 'household-1', + telegramUserId: input.telegramUserId ?? `${input.id}-tg`, + displayName: input.displayName ?? input.id, + status: input.status ?? 'active', + preferredLocale: input.preferredLocale ?? 'ru', + householdDefaultLocale: input.householdDefaultLocale ?? 'ru', + rentShareWeight: input.rentShareWeight ?? 1, + isAdmin: input.isAdmin ?? false + } +} + +function createHouseholdRepository() { + const members = [ + member({ id: 'dima', telegramUserId: '10002', displayName: 'Дима' }), + member({ id: 'georgiy', displayName: 'Георгий' }) + ] + const settings: HouseholdBillingSettingsRecord = { + householdId: 'household-1', + settlementCurrency: 'GEL', + rentAmountMinor: 0n, + rentCurrency: 'GEL', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi', + paymentBalanceAdjustmentPolicy: 'utilities', + rentPaymentDestinations: null + } + const assistantConfig: HouseholdAssistantConfigRecord = { + householdId: 'household-1', + assistantContext: null, + assistantTone: 'Playful' + } + + return { + async getTelegramHouseholdChat() { + return { + householdId: 'household-1', + householdName: 'Kojori', + telegramChatId: '-10012345', + telegramChatType: 'supergroup' as const, + title: 'Kojori', + defaultLocale: 'ru' as const + } + }, + async findHouseholdTopicByTelegramContext() { + return { + householdId: 'household-1', + role: 'reminders' as const, + telegramThreadId: '777', + topicName: 'Напоминания' + } + }, + async getHouseholdMember(householdId: string, telegramUserId: string) { + return ( + members.find( + (entry) => entry.householdId === householdId && entry.telegramUserId === telegramUserId + ) ?? null + ) + }, + async listHouseholdMembers() { + return members + }, + async getHouseholdBillingSettings() { + return settings + }, + async getHouseholdAssistantConfig() { + return assistantConfig + } + } +} + +describe('registerAdHocNotifications', () => { + test('shows the final rendered reminder text and persists that same text on confirm', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + const scheduledRequests: Array<{ notificationText: string }> = [] + + 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 interpreter: AdHocNotificationInterpreter = { + async interpretRequest() { + return { + decision: 'notification', + notificationText: 'пошпынять Георгия о том, позвонил ли он', + assigneeMemberId: 'georgiy', + resolvedLocalDate: '2026-03-24', + resolvedHour: 9, + resolvedMinute: 0, + resolutionMode: 'fuzzy_window', + clarificationQuestion: null, + confidence: 90, + parserMode: 'llm' + } + }, + async interpretSchedule() { + return { + decision: 'parsed', + resolvedLocalDate: '2026-03-24', + resolvedHour: 9, + resolvedMinute: 0, + resolutionMode: 'fuzzy_window', + clarificationQuestion: null, + confidence: 90, + parserMode: 'llm' + } + }, + async renderDeliveryText(input) { + expect(input.requesterDisplayName).toBe('Дима') + expect(input.assigneeDisplayName).toBe('Георгий') + return 'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.' + } + } + + const notificationService: AdHocNotificationService = { + async scheduleNotification(input) { + scheduledRequests.push({ notificationText: input.notificationText }) + 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 listDueNotifications() { + return [] + }, + async claimDueNotification() { + return false + }, + async releaseDueNotification() {}, + async markNotificationSent() { + return null + } + } + + registerAdHocNotifications({ + bot, + householdConfigurationRepository: createHouseholdRepository() as never, + promptRepository, + notificationService, + reminderInterpreter: interpreter + }) + + await bot.handleUpdate( + reminderMessageUpdate('Железяка, напомни пошпынять Георгия завтра с утра') as never + ) + + expect(calls[0]?.method).toBe('sendMessage') + expect(calls[0]?.payload).toMatchObject({ + text: expect.stringContaining('Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.') + }) + + 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) + + expect(scheduledRequests).toEqual([ + { + notificationText: 'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.' + } + ]) + }) + + test('reports temporary unavailability when the reminder interpreter is missing', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + 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: true + } 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: undefined + }) + + await bot.handleUpdate(reminderMessageUpdate('напомни завтра') as never) + + expect(calls[0]?.payload).toMatchObject({ + text: 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.' + }) + }) +}) diff --git a/apps/bot/src/ad-hoc-notifications.ts b/apps/bot/src/ad-hoc-notifications.ts index f85d061..5fe9195 100644 --- a/apps/bot/src/ad-hoc-notifications.ts +++ b/apps/bot/src/ad-hoc-notifications.ts @@ -3,6 +3,7 @@ import { Temporal, nowInstant } from '@household/domain' import type { Logger } from '@household/observability' import type { AdHocNotificationDeliveryMode, + HouseholdAssistantConfigRecord, HouseholdConfigurationRepository, HouseholdMemberRecord, TelegramPendingActionRepository @@ -10,12 +11,13 @@ import type { import type { Bot, Context } from 'grammy' import type { InlineKeyboardMarkup } from 'grammy/types' -import { - parseAdHocNotificationRequest, - parseAdHocNotificationSchedule -} from './ad-hoc-notification-parser' +import { parseAdHocNotificationSchedule } from './ad-hoc-notification-parser' import { resolveReplyLocale } from './bot-locale' import type { BotLocale } from './i18n' +import type { + AdHocNotificationInterpreter, + AdHocNotificationInterpreterMember +} from './openai-ad-hoc-notification-interpreter' const AD_HOC_NOTIFICATION_ACTION = 'ad_hoc_notification' as const const AD_HOC_NOTIFICATION_ACTION_TTL_MS = 30 * 60_000 @@ -23,7 +25,6 @@ const AD_HOC_NOTIFICATION_CONFIRM_PREFIX = 'adhocnotif:confirm:' const AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX = 'adhocnotif:canceldraft:' const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:' const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:' -const AD_HOC_NOTIFICATION_FRIENDLY_PREFIX = 'adhocnotif:friendly:' const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:' type NotificationDraftPayload = @@ -35,11 +36,10 @@ type NotificationDraftPayload = creatorMemberId: string timezone: string originalRequestText: string - notificationText: string + normalizedNotificationText: string assigneeMemberId: string | null deliveryMode: AdHocNotificationDeliveryMode dmRecipientMemberIds: readonly string[] - friendlyTagAssignee: boolean } | { stage: 'confirm' @@ -49,13 +49,13 @@ type NotificationDraftPayload = creatorMemberId: string timezone: string originalRequestText: string - notificationText: string + normalizedNotificationText: string + renderedNotificationText: string assigneeMemberId: string | null scheduledForIso: string timePrecision: 'exact' | 'date_only_defaulted' deliveryMode: AdHocNotificationDeliveryMode dmRecipientMemberIds: readonly string[] - friendlyTagAssignee: boolean } interface ReminderTopicContext { @@ -65,6 +65,32 @@ interface ReminderTopicContext { member: HouseholdMemberRecord members: readonly HouseholdMemberRecord[] timezone: string + assistantContext: string | null + assistantTone: string | null +} + +function unavailableReply(locale: BotLocale): string { + return locale === 'ru' + ? 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.' + : 'I cannot create reminders right now because the AI module is temporarily unavailable.' +} + +function localNowText(timezone: string, now = nowInstant()): string { + const local = now.toZonedDateTimeISO(timezone) + return [ + local.toPlainDate().toString(), + `${String(local.hour).padStart(2, '0')}:${String(local.minute).padStart(2, '0')}` + ].join(' ') +} + +function interpreterMembers( + members: readonly HouseholdMemberRecord[] +): readonly AdHocNotificationInterpreterMember[] { + return members.map((member) => ({ + memberId: member.id, + displayName: member.displayName, + status: member.status + })) } function createProposalId(): string { @@ -152,15 +178,14 @@ function notificationSummaryText(input: { return [ 'Запланировать напоминание?', '', - `Текст: ${input.payload.notificationText}`, + `Текст напоминания: ${input.payload.renderedNotificationText}`, `Когда: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, - `Точность: ${input.payload.timePrecision === 'date_only_defaulted' ? 'время по умолчанию 12:00' : 'точное время'}`, + `Точность: ${input.payload.timePrecision === 'date_only_defaulted' ? 'время определено ботом' : 'точное время'}`, `Куда: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, assignee ? `Ответственный: ${assignee.displayName}` : null, input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 ? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}` : null, - assignee ? `Дружелюбный тег: ${input.payload.friendlyTagAssignee ? 'вкл' : 'выкл'}` : null, '', 'Подтвердите или измените настройки ниже.' ] @@ -171,15 +196,14 @@ function notificationSummaryText(input: { return [ 'Schedule this notification?', '', - `Text: ${input.payload.notificationText}`, + `Reminder text: ${input.payload.renderedNotificationText}`, `When: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, - `Precision: ${input.payload.timePrecision === 'date_only_defaulted' ? 'defaulted to 12:00' : 'exact time'}`, + `Precision: ${input.payload.timePrecision === 'date_only_defaulted' ? 'inferred/defaulted time' : 'exact time'}`, `Delivery: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, assignee ? `Assignee: ${assignee.displayName}` : null, input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 ? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}` : null, - assignee ? `Friendly tag: ${input.payload.friendlyTagAssignee ? 'on' : 'off'}` : null, '', 'Confirm or adjust below.' ] @@ -223,15 +247,6 @@ function notificationDraftReplyMarkup( ] ] - if (payload.assigneeMemberId) { - rows.push([ - { - text: `${payload.friendlyTagAssignee ? '✅ ' : ''}${locale === 'ru' ? 'Тегнуть ответственного' : 'Friendly tag assignee'}`, - callback_data: `${AD_HOC_NOTIFICATION_FRIENDLY_PREFIX}${payload.proposalId}` - } - ]) - } - if (payload.deliveryMode === 'dm_selected') { const eligibleMembers = members.filter((member) => member.status === 'active') for (const member of eligibleMembers) { @@ -331,7 +346,7 @@ async function resolveReminderTopicContext( return null } - const [locale, member, members, settings] = await Promise.all([ + const [locale, member, members, settings, assistantConfig] = await Promise.all([ resolveReplyLocale({ ctx, repository, @@ -339,7 +354,14 @@ async function resolveReminderTopicContext( }), repository.getHouseholdMember(binding.householdId, telegramUserId), repository.listHouseholdMembers(binding.householdId), - repository.getHouseholdBillingSettings(binding.householdId) + repository.getHouseholdBillingSettings(binding.householdId), + repository.getHouseholdAssistantConfig + ? repository.getHouseholdAssistantConfig(binding.householdId) + : Promise.resolve({ + householdId: binding.householdId, + assistantContext: null, + assistantTone: null + }) ]) if (!member) { @@ -352,7 +374,9 @@ async function resolveReminderTopicContext( threadId, member, members, - timezone: settings.timezone + timezone: settings.timezone, + assistantContext: assistantConfig.assistantContext, + assistantTone: assistantConfig.assistantTone } } @@ -397,8 +421,32 @@ export function registerAdHocNotifications(options: { householdConfigurationRepository: HouseholdConfigurationRepository promptRepository: TelegramPendingActionRepository notificationService: AdHocNotificationService + reminderInterpreter: AdHocNotificationInterpreter | undefined logger?: Logger }): void { + async function renderNotificationText(input: { + reminderContext: ReminderTopicContext + originalRequestText: string + normalizedNotificationText: string + assigneeMemberId: string | null + }): Promise { + const assignee = input.assigneeMemberId + ? input.reminderContext.members.find((member) => member.id === input.assigneeMemberId) + : null + + return ( + options.reminderInterpreter?.renderDeliveryText({ + locale: input.reminderContext.locale, + originalRequestText: input.originalRequestText, + notificationText: input.normalizedNotificationText, + requesterDisplayName: input.reminderContext.member.displayName, + assigneeDisplayName: assignee?.displayName ?? null, + assistantContext: input.reminderContext.assistantContext, + assistantTone: input.reminderContext.assistantTone + }) ?? null + ) + } + async function showDraftConfirmation( ctx: Context, draft: Extract @@ -523,17 +571,48 @@ export function registerAdHocNotifications(options: { const existingDraft = await loadDraft(options.promptRepository, ctx) if (existingDraft && existingDraft.threadId === reminderContext.threadId) { if (existingDraft.stage === 'await_schedule') { + if (!options.reminderInterpreter) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + const interpretedSchedule = await options.reminderInterpreter.interpretSchedule({ + locale: reminderContext.locale, + timezone: existingDraft.timezone, + localNow: localNowText(existingDraft.timezone), + text: messageText + }) + + if (!interpretedSchedule) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + if (interpretedSchedule.decision === 'clarification') { + await replyInTopic( + ctx, + interpretedSchedule.clarificationQuestion ?? + (reminderContext.locale === 'ru' + ? 'Когда напомнить? Напишите день, дату или время.' + : 'When should I remind? Please send a day, date, or time.') + ) + return + } + const schedule = parseAdHocNotificationSchedule({ - text: messageText, - timezone: existingDraft.timezone + timezone: existingDraft.timezone, + resolvedLocalDate: interpretedSchedule.resolvedLocalDate, + resolvedHour: interpretedSchedule.resolvedHour, + resolvedMinute: interpretedSchedule.resolvedMinute, + resolutionMode: interpretedSchedule.resolutionMode }) if (schedule.kind === 'missing_schedule') { await replyInTopic( ctx, reminderContext.locale === 'ru' - ? 'Нужны хотя бы день или дата. Например: «завтра», «24.03», «2026-03-24 18:30».' - : 'I still need at least a day or date. For example: "tomorrow", "2026-03-24", or "2026-03-24 18:30".' + ? 'Нужны дата или понятное время. Например: «завтра утром», «24.03», «2026-03-24 18:30».' + : 'I still need a date or a clear time. For example: "tomorrow morning", "2026-03-24", or "2026-03-24 18:30".' ) return } @@ -548,9 +627,21 @@ export function registerAdHocNotifications(options: { return } + const renderedNotificationText = await renderNotificationText({ + reminderContext, + originalRequestText: existingDraft.originalRequestText, + normalizedNotificationText: existingDraft.normalizedNotificationText, + assigneeMemberId: existingDraft.assigneeMemberId + }) + if (!renderedNotificationText) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + const confirmPayload: Extract = { ...existingDraft, stage: 'confirm', + renderedNotificationText, scheduledForIso: schedule.scheduledFor!.toString(), timePrecision: schedule.timePrecision! } @@ -563,20 +654,33 @@ export function registerAdHocNotifications(options: { return } - const parsed = parseAdHocNotificationRequest({ + if (!options.reminderInterpreter) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + const interpretedRequest = await options.reminderInterpreter.interpretRequest({ text: messageText, timezone: reminderContext.timezone, locale: reminderContext.locale, - members: reminderContext.members, - senderMemberId: reminderContext.member.id + localNow: localNowText(reminderContext.timezone), + members: interpreterMembers(reminderContext.members), + senderMemberId: reminderContext.member.id, + assistantContext: reminderContext.assistantContext, + assistantTone: reminderContext.assistantTone }) - if (parsed.kind === 'not_intent') { + if (!interpretedRequest) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + if (interpretedRequest.decision === 'not_notification') { await next() return } - if (!parsed.notificationText || parsed.notificationText.length === 0) { + if (!interpretedRequest.notificationText || interpretedRequest.notificationText.length === 0) { await replyInTopic( ctx, reminderContext.locale === 'ru' @@ -586,32 +690,42 @@ export function registerAdHocNotifications(options: { return } - if (parsed.kind === 'missing_schedule') { - await saveDraft(options.promptRepository, ctx, { - stage: 'await_schedule', - proposalId: createProposalId(), - householdId: reminderContext.householdId, - threadId: reminderContext.threadId, - creatorMemberId: reminderContext.member.id, - timezone: reminderContext.timezone, - originalRequestText: parsed.originalRequestText, - notificationText: parsed.notificationText, - assigneeMemberId: parsed.assigneeMemberId, - deliveryMode: 'topic', - dmRecipientMemberIds: [], - friendlyTagAssignee: false - }) + if (interpretedRequest.decision === 'clarification') { + if (interpretedRequest.notificationText) { + await saveDraft(options.promptRepository, ctx, { + stage: 'await_schedule', + proposalId: createProposalId(), + householdId: reminderContext.householdId, + threadId: reminderContext.threadId, + creatorMemberId: reminderContext.member.id, + timezone: reminderContext.timezone, + originalRequestText: messageText, + normalizedNotificationText: interpretedRequest.notificationText, + assigneeMemberId: interpretedRequest.assigneeMemberId, + deliveryMode: 'topic', + dmRecipientMemberIds: [] + }) + } await replyInTopic( ctx, - reminderContext.locale === 'ru' - ? 'Когда напомнить? Подойдёт свободная форма, например: «завтра», «завтра в 15:00», «24.03 18:30».' - : 'When should I remind? Free-form is fine, for example: "tomorrow", "tomorrow 15:00", or "2026-03-24 18:30".' + interpretedRequest.clarificationQuestion ?? + (reminderContext.locale === 'ru' + ? 'Когда напомнить? Подойдёт свободная форма, например: «завтра утром», «завтра в 15:00», «24.03 18:30».' + : 'When should I remind? Free-form is fine, for example: "tomorrow morning", "tomorrow 15:00", or "2026-03-24 18:30".') ) return } - if (parsed.kind === 'invalid_past') { + const parsedSchedule = parseAdHocNotificationSchedule({ + timezone: reminderContext.timezone, + resolvedLocalDate: interpretedRequest.resolvedLocalDate, + resolvedHour: interpretedRequest.resolvedHour, + resolvedMinute: interpretedRequest.resolvedMinute, + resolutionMode: interpretedRequest.resolutionMode + }) + + if (parsedSchedule.kind === 'invalid_past') { await replyInTopic( ctx, reminderContext.locale === 'ru' @@ -621,6 +735,28 @@ export function registerAdHocNotifications(options: { return } + if (parsedSchedule.kind !== 'parsed') { + await replyInTopic( + ctx, + interpretedRequest.clarificationQuestion ?? + (reminderContext.locale === 'ru' + ? 'Когда напомнить? Подойдёт свободная форма, например: «завтра утром», «завтра в 15:00», «24.03 18:30».' + : 'When should I remind? Free-form is fine, for example: "tomorrow morning", "tomorrow 15:00", or "2026-03-24 18:30".') + ) + return + } + + const renderedNotificationText = await renderNotificationText({ + reminderContext, + originalRequestText: messageText, + normalizedNotificationText: interpretedRequest.notificationText, + assigneeMemberId: interpretedRequest.assigneeMemberId + }) + if (!renderedNotificationText) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + const draft: Extract = { stage: 'confirm', proposalId: createProposalId(), @@ -628,14 +764,14 @@ export function registerAdHocNotifications(options: { threadId: reminderContext.threadId, creatorMemberId: reminderContext.member.id, timezone: reminderContext.timezone, - originalRequestText: parsed.originalRequestText, - notificationText: parsed.notificationText, - assigneeMemberId: parsed.assigneeMemberId, - scheduledForIso: parsed.scheduledFor!.toString(), - timePrecision: parsed.timePrecision!, + originalRequestText: messageText, + normalizedNotificationText: interpretedRequest.notificationText, + renderedNotificationText, + assigneeMemberId: interpretedRequest.assigneeMemberId, + scheduledForIso: parsedSchedule.scheduledFor!.toString(), + timePrecision: parsedSchedule.timePrecision!, deliveryMode: 'topic', - dmRecipientMemberIds: [], - friendlyTagAssignee: false + dmRecipientMemberIds: [] } await saveDraft(options.promptRepository, ctx, draft) @@ -670,14 +806,14 @@ export function registerAdHocNotifications(options: { householdId: payload.householdId, creatorMemberId: payload.creatorMemberId, originalRequestText: payload.originalRequestText, - notificationText: payload.notificationText, + notificationText: payload.renderedNotificationText, timezone: payload.timezone, scheduledFor: Temporal.Instant.from(payload.scheduledForIso), timePrecision: payload.timePrecision, deliveryMode: payload.deliveryMode, assigneeMemberId: payload.assigneeMemberId, dmRecipientMemberIds: payload.dmRecipientMemberIds, - friendlyTagAssignee: payload.friendlyTagAssignee, + friendlyTagAssignee: false, sourceTelegramChatId: ctx.chat?.id?.toString() ?? null, sourceTelegramThreadId: payload.threadId }) @@ -769,22 +905,6 @@ export function registerAdHocNotifications(options: { return } - if (data.startsWith(AD_HOC_NOTIFICATION_FRIENDLY_PREFIX)) { - const proposalId = data.slice(AD_HOC_NOTIFICATION_FRIENDLY_PREFIX.length) - const payload = await loadDraft(options.promptRepository, ctx) - if (!payload || payload.stage !== 'confirm' || payload.proposalId !== proposalId) { - await next() - return - } - - await refreshConfirmationMessage(ctx, { - ...payload, - friendlyTagAssignee: !payload.friendlyTagAssignee - }) - await ctx.answerCallbackQuery() - return - } - if (data.startsWith(AD_HOC_NOTIFICATION_MEMBER_PREFIX)) { const rest = data.slice(AD_HOC_NOTIFICATION_MEMBER_PREFIX.length) const separatorIndex = rest.indexOf(':') @@ -863,24 +983,10 @@ export function registerAdHocNotifications(options: { }) } -export function buildTopicNotificationText(input: { - notificationText: string - assignee?: { - displayName: string - telegramUserId: string - } | null - friendlyTagAssignee: boolean -}): { +export function buildTopicNotificationText(input: { notificationText: string }): { text: string parseMode: 'HTML' } { - if (input.friendlyTagAssignee && input.assignee) { - return { - text: `${escapeHtml(input.assignee.displayName)}, ${escapeHtml(input.notificationText)}`, - parseMode: 'HTML' - } - } - return { text: escapeHtml(input.notificationText), parseMode: 'HTML' diff --git a/apps/bot/src/app.ts b/apps/bot/src/app.ts index 3e6955f..4bc702b 100644 --- a/apps/bot/src/app.ts +++ b/apps/bot/src/app.ts @@ -75,6 +75,7 @@ import { import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' import { createNbgExchangeRateProvider } from './nbg-exchange-rates' import { createOpenAiChatAssistant } from './openai-chat-assistant' +import { createOpenAiAdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter' import { createPurchaseMessageRepository, @@ -152,6 +153,12 @@ export async function createBotRuntimeApp(): Promise { runtime.openaiApiKey, runtime.purchaseParserModel ) + const adHocNotificationInterpreter = createOpenAiAdHocNotificationInterpreter({ + apiKey: runtime.openaiApiKey, + parserModel: runtime.purchaseParserModel, + rendererModel: runtime.assistantModel, + timeoutMs: runtime.assistantTimeoutMs + }) const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore( runtime.assistantMemoryMaxTurns ) @@ -403,6 +410,7 @@ export async function createBotRuntimeApp(): Promise { householdConfigurationRepository: householdConfigurationRepositoryClient.repository, promptRepository: telegramPendingActionRepositoryClient.repository, notificationService: adHocNotificationService, + reminderInterpreter: adHocNotificationInterpreter, logger: getLogger('ad-hoc-notifications') }) } diff --git a/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts new file mode 100644 index 0000000..5b9baf0 --- /dev/null +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from 'bun:test' + +import { + createOpenAiAdHocNotificationInterpreter, + type AdHocNotificationInterpretation +} from './openai-ad-hoc-notification-interpreter' + +function successfulResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { + 'content-type': 'application/json' + } + }) +} + +function nestedJsonResponse(payload: unknown): Response { + return successfulResponse({ + output: [ + { + content: [ + { + text: JSON.stringify(payload) + } + ] + } + ] + }) +} + +describe('createOpenAiAdHocNotificationInterpreter', () => { + test('parses exact datetime requests through the llm', 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: 'notification', + notificationText: 'пошпынять Георгия о том, позвонил ли он', + assigneeMemberId: 'georgiy', + resolvedLocalDate: '2026-03-24', + resolvedHour: 15, + resolvedMinute: 30, + resolutionMode: 'exact', + confidence: 93, + clarificationQuestion: null + })) as unknown as typeof fetch + + try { + const result = await interpreter!.interpretRequest({ + locale: 'ru', + timezone: 'Asia/Tbilisi', + localNow: '2026-03-23 13:00', + text: 'Железяка, напомни пошпынять Георгия завтра в 15:30', + members: [ + { memberId: 'dima', displayName: 'Дима', status: 'active' }, + { memberId: 'georgiy', displayName: 'Георгий', status: 'active' } + ], + senderMemberId: 'dima' + }) + + expect(result).toEqual({ + decision: 'notification', + notificationText: 'пошпынять Георгия о том, позвонил ли он', + assigneeMemberId: 'georgiy', + resolvedLocalDate: '2026-03-24', + resolvedHour: 15, + resolvedMinute: 30, + resolutionMode: 'exact', + clarificationQuestion: null, + confidence: 93, + parserMode: 'llm' + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test('parses fuzzy windows like tomorrow morning', 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: 'notification', + notificationText: 'remind me about the call', + assigneeMemberId: null, + resolvedLocalDate: '2026-03-24', + resolvedHour: 9, + resolvedMinute: 0, + resolutionMode: 'fuzzy_window', + confidence: 90, + clarificationQuestion: null + })) as unknown as typeof fetch + + try { + const result = await interpreter!.interpretRequest({ + locale: 'en', + timezone: 'Asia/Tbilisi', + localNow: '2026-03-23 13:00', + text: 'remind me tomorrow morning about the call', + members: [], + senderMemberId: 'sender' + }) + + expect(result?.resolutionMode).toBe('fuzzy_window') + expect(result?.resolvedHour).toBe(9) + expect(result?.resolvedMinute).toBe(0) + } finally { + globalThis.fetch = originalFetch + } + }) + + test('returns clarification for missing or ambiguous schedule', 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: 'clarification', + notificationText: 'пошпынять Георгия о том, позвонил ли он', + assigneeMemberId: 'georgiy', + resolvedLocalDate: null, + resolvedHour: null, + resolvedMinute: null, + resolutionMode: 'ambiguous', + confidence: 82, + clarificationQuestion: 'Когда напомнить: завтра утром, днем или вечером?' + })) as unknown as typeof fetch + + try { + const result = await interpreter!.interpretRequest({ + locale: 'ru', + timezone: 'Asia/Tbilisi', + localNow: '2026-03-23 13:00', + text: 'напомни пошпынять Георгия', + members: [ + { memberId: 'dima', displayName: 'Дима', status: 'active' }, + { memberId: 'georgiy', displayName: 'Георгий', status: 'active' } + ], + senderMemberId: 'dima' + }) + + expect(result?.decision).toBe('clarification') + expect(result?.clarificationQuestion).toContain('Когда напомнить') + expect(result?.notificationText).toContain('пошпынять Георгия') + } finally { + globalThis.fetch = originalFetch + } + }) + + test('returns not_notification for unrelated text', 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: 'not_notification', + notificationText: null, + assigneeMemberId: null, + resolvedLocalDate: null, + resolvedHour: null, + resolvedMinute: null, + resolutionMode: null, + confidence: 96, + clarificationQuestion: null + })) as unknown as typeof fetch + + try { + const result = await interpreter!.interpretRequest({ + locale: 'ru', + timezone: 'Asia/Tbilisi', + localNow: '2026-03-23 13:00', + text: 'как дела', + members: [], + senderMemberId: 'sender' + }) + + expect(result?.decision).toBe('not_notification') + } finally { + globalThis.fetch = originalFetch + } + }) + + test('renders the final delivery text that should be persisted', async () => { + const interpreter = createOpenAiAdHocNotificationInterpreter({ + apiKey: 'test-key', + parserModel: 'gpt-5-mini', + rendererModel: 'gpt-5-mini', + timeoutMs: 5000 + }) + expect(interpreter).toBeDefined() + + const originalFetch = globalThis.fetch + let capturedPrompt = '' + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const body = + typeof init?.body === 'string' + ? (JSON.parse(init.body) as { + input?: Array<{ content?: string }> + }) + : null + capturedPrompt = body?.input?.[0]?.content ?? '' + + return nestedJsonResponse({ + text: 'Дима, пора пошпынять Георгия и проверить, позвонил ли он уже.' + }) + }) as unknown as typeof fetch + + try { + const result = await interpreter!.renderDeliveryText({ + locale: 'ru', + originalRequestText: 'Железяка, напомни пошпынять Георгия о том позвонил ли он.', + notificationText: 'пошпынять Георгия о том, позвонил ли он', + requesterDisplayName: 'Дима', + assigneeDisplayName: 'Георгий' + }) + + expect(capturedPrompt).toContain('Requester display name: Дима') + expect(capturedPrompt).toContain('Assignee display name: Георгий') + expect(capturedPrompt).toContain('Do not accidentally address the assignee as the recipient') + expect(result).toBe('Дима, пора пошпынять Георгия и проверить, позвонил ли он уже.') + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/apps/bot/src/openai-ad-hoc-notification-interpreter.ts b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts new file mode 100644 index 0000000..8cbcea7 --- /dev/null +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts @@ -0,0 +1,490 @@ +import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses' + +export type AdHocNotificationResolutionMode = 'exact' | 'fuzzy_window' | 'date_only' | 'ambiguous' + +export interface AdHocNotificationInterpreterMember { + memberId: string + displayName: string + status: 'active' | 'away' | 'left' +} + +export interface AdHocNotificationInterpretation { + decision: 'notification' | 'clarification' | 'not_notification' + notificationText: string | null + assigneeMemberId: string | null + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null + clarificationQuestion: string | null + confidence: number + parserMode: 'llm' +} + +export interface AdHocNotificationScheduleInterpretation { + decision: 'parsed' | 'clarification' + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null + clarificationQuestion: string | null + confidence: number + parserMode: 'llm' +} + +interface ReminderInterpretationResult { + decision: 'notification' | 'clarification' | 'not_notification' + notificationText: string | null + assigneeMemberId: string | null + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null + confidence: number + clarificationQuestion: string | null +} + +interface ReminderScheduleResult { + decision: 'parsed' | 'clarification' + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null + confidence: number + clarificationQuestion: string | null +} + +interface ReminderDeliveryTextResult { + text: string | null +} + +export interface AdHocNotificationInterpreter { + interpretRequest(input: { + locale: 'en' | 'ru' + timezone: string + localNow: string + text: string + members: readonly AdHocNotificationInterpreterMember[] + senderMemberId: string + assistantContext?: string | null + assistantTone?: string | null + }): Promise + interpretSchedule(input: { + locale: 'en' | 'ru' + timezone: string + localNow: string + text: string + }): Promise + renderDeliveryText(input: { + locale: 'en' | 'ru' + originalRequestText: string + notificationText: string + requesterDisplayName?: string | null + assigneeDisplayName?: string | null + assistantContext?: string | null + assistantTone?: string | null + }): Promise +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed && trimmed.length > 0 ? trimmed : null +} + +function normalizeConfidence(value: number): number { + const scaled = value >= 0 && value <= 1 ? value * 100 : value + return Math.max(0, Math.min(100, Math.round(scaled))) +} + +function normalizeResolutionMode( + value: string | null | undefined +): AdHocNotificationResolutionMode | null { + return value === 'exact' || + value === 'fuzzy_window' || + value === 'date_only' || + value === 'ambiguous' + ? value + : null +} + +function normalizeHour(value: number | null | undefined): number | null { + if ( + value === null || + value === undefined || + !Number.isInteger(value) || + value < 0 || + value > 23 + ) { + return null + } + + return value +} + +function normalizeMinute(value: number | null | undefined): number | null { + if ( + value === null || + value === undefined || + !Number.isInteger(value) || + value < 0 || + value > 59 + ) { + return null + } + + return value +} + +function normalizeMemberId( + value: string | null | undefined, + members: readonly AdHocNotificationInterpreterMember[] +): string | null { + const trimmed = value?.trim() + if (!trimmed) { + return null + } + + return members.some((member) => member.memberId === trimmed) ? trimmed : null +} + +function promptWindowRules(): string { + return [ + 'Resolve fuzzy windows using these exact defaults:', + '- morning / утром / с утра => 09:00', + '- before lunch / до обеда => 11:00', + '- afternoon / днём / днем => 14:00', + '- evening / вечером => 19:00', + '- plain date-only without time => 12:00' + ].join('\n') +} + +function rosterText( + members: readonly AdHocNotificationInterpreterMember[], + senderMemberId: string +): string { + if (members.length === 0) { + return 'No household roster provided.' + } + + return [ + 'Household members:', + ...members.map( + (member) => + `- ${member.memberId}: ${member.displayName} (status=${member.status}${member.memberId === senderMemberId ? ', sender=yes' : ''})` + ) + ].join('\n') +} + +async function fetchStructuredResult(input: { + apiKey: string + model: string + schemaName: string + schema: object + prompt: string + timeoutMs: number +}): Promise { + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), input.timeoutMs) + + try { + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + signal: abortController.signal, + headers: { + authorization: `Bearer ${input.apiKey}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model: input.model, + input: [ + { + role: 'system', + content: input.prompt + } + ], + text: { + format: { + type: 'json_schema', + name: input.schemaName, + schema: input.schema + } + } + }) + }) + + if (!response.ok) { + return null + } + + const payload = (await response.json()) as { + output_text?: string | null + output?: Array<{ + content?: Array<{ + text?: string | { value?: string | null } | null + }> | null + }> | null + } + const responseText = extractOpenAiResponseText(payload) + if (!responseText) { + return null + } + + return parseJsonFromResponseText(responseText) + } finally { + clearTimeout(timeout) + } +} + +export function createOpenAiAdHocNotificationInterpreter(input: { + apiKey: string | undefined + parserModel: string + rendererModel: string + timeoutMs: number +}): AdHocNotificationInterpreter | undefined { + if (!input.apiKey) { + return undefined + } + + const apiKey = input.apiKey + const parserModel = input.parserModel + const rendererModel = input.rendererModel + const timeoutMs = input.timeoutMs + + return { + async interpretRequest(options) { + const parsed = await fetchStructuredResult({ + apiKey, + model: parserModel, + schemaName: 'ad_hoc_notification_interpretation', + schema: { + type: 'object', + additionalProperties: false, + properties: { + decision: { + type: 'string', + enum: ['notification', 'clarification', 'not_notification'] + }, + notificationText: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, + 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' } + ] + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 100 + }, + clarificationQuestion: { + anyOf: [{ type: 'string' }, { type: 'null' }] + } + }, + required: [ + 'decision', + 'notificationText', + 'assigneeMemberId', + 'resolvedLocalDate', + 'resolvedHour', + 'resolvedMinute', + 'resolutionMode', + 'confidence', + 'clarificationQuestion' + ] + }, + prompt: [ + 'You interpret messages from a household reminders topic.', + 'Decide whether the latest message is an ad hoc reminder request, needs clarification, or is not a reminder request.', + 'Return notificationText as the normalized reminder meaning, without schedule words.', + '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.', + '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.', + 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), + '', + 'Latest user message:', + options.text + ] + .filter(Boolean) + .join('\n'), + timeoutMs + }) + + if (!parsed) { + return null + } + + return { + decision: + parsed.decision === 'notification' || + parsed.decision === 'clarification' || + parsed.decision === 'not_notification' + ? parsed.decision + : 'not_notification', + notificationText: normalizeOptionalText(parsed.notificationText), + assigneeMemberId: normalizeMemberId(parsed.assigneeMemberId, options.members), + resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate), + resolvedHour: normalizeHour(parsed.resolvedHour), + resolvedMinute: normalizeMinute(parsed.resolvedMinute), + resolutionMode: normalizeResolutionMode(parsed.resolutionMode), + clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion), + confidence: normalizeConfidence(parsed.confidence), + parserMode: 'llm' + } + }, + + async interpretSchedule(options) { + const parsed = await fetchStructuredResult({ + apiKey, + model: parserModel, + schemaName: 'ad_hoc_notification_schedule', + schema: { + type: 'object', + additionalProperties: false, + properties: { + decision: { + type: 'string', + enum: ['parsed', 'clarification'] + }, + 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' } + ] + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 100 + }, + clarificationQuestion: { + anyOf: [{ type: 'string' }, { type: 'null' }] + } + }, + required: [ + 'decision', + 'resolvedLocalDate', + 'resolvedHour', + 'resolvedMinute', + 'resolutionMode', + 'confidence', + 'clarificationQuestion' + ] + }, + prompt: [ + 'You interpret only the schedule part of a reminder follow-up.', + '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.', + '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.', + promptWindowRules(), + `Household timezone: ${options.timezone}`, + `Current local date/time in that timezone: ${options.localNow}`, + '', + 'Latest user message:', + options.text + ].join('\n'), + timeoutMs + }) + + if (!parsed) { + return null + } + + return { + decision: parsed.decision === 'parsed' ? 'parsed' : 'clarification', + resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate), + resolvedHour: normalizeHour(parsed.resolvedHour), + resolvedMinute: normalizeMinute(parsed.resolvedMinute), + resolutionMode: normalizeResolutionMode(parsed.resolutionMode), + clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion), + confidence: normalizeConfidence(parsed.confidence), + parserMode: 'llm' + } + }, + + async renderDeliveryText(options) { + const parsed = await fetchStructuredResult({ + apiKey, + model: rendererModel, + schemaName: 'ad_hoc_notification_delivery_text', + schema: { + type: 'object', + additionalProperties: false, + properties: { + text: { + anyOf: [{ type: 'string' }, { type: 'null' }] + } + }, + required: ['text'] + }, + prompt: [ + 'You write the final text of a scheduled household reminder message.', + 'Be helpful and lightly playful by default.', + 'Keep the meaning very close to the underlying reminder intent.', + 'Do not mention the schedule or time; the reminder is being sent now.', + 'Prefer one short sentence.', + 'This reminder is being delivered to the requester and/or household chat, not automatically to the assignee.', + 'If requesterDisplayName is provided, prefer addressing the requester or keeping the line neutral.', + 'If assigneeDisplayName is provided, treat that person as the subject of the reminder unless the original request clearly says the reminder should speak directly to them.', + 'Do not accidentally address the assignee as the recipient when the reminder is actually for someone else to act on.', + 'Do not use bullet lists or explanations.', + options.assistantContext ? `Household context: ${options.assistantContext}` : null, + options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null, + `Locale: ${options.locale}`, + options.requesterDisplayName + ? `Requester display name: ${options.requesterDisplayName}` + : null, + options.assigneeDisplayName + ? `Assignee display name: ${options.assigneeDisplayName}` + : null, + `Original user request: ${options.originalRequestText}`, + `Normalized reminder intent: ${options.notificationText}`, + 'Return only JSON.' + ] + .filter(Boolean) + .join('\n'), + timeoutMs + }) + + return normalizeOptionalText(parsed?.text) + } + } +}