feat(bot): move ad hoc notifications to llm parsing

This commit is contained in:
2026-03-24 02:39:22 +04:00
parent 3507bc0522
commit fedc4a4325
9 changed files with 1428 additions and 482 deletions

View File

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

View File

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

View File

@@ -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<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
): 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')
})

View File

@@ -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<string>([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<string>([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({
const hour =
input.resolutionMode === 'date_only' ? (input.resolvedHour ?? 12) : input.resolvedHour
const minute =
input.resolutionMode === 'date_only' ? (input.resolvedMinute ?? 0) : input.resolvedMinute
if (hour === null || minute === null) {
return {
kind: 'missing_schedule',
scheduledFor: null,
timePrecision: null
}
}
try {
const date = Temporal.PlainDate.from(input.resolvedLocalDate)
const scheduled = 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,
year: date.year,
month: date.month,
day: date.day,
hour,
minute,
second: 0,
millisecond: 0
}).toInstant()
if (scheduledDateTime.epochMilliseconds <= referenceInstant.epochMilliseconds) {
const effectiveNow = input.now ?? nowInstant()
if (scheduled.epochMilliseconds <= effectiveNow.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) {
return {
kind: 'parsed',
scheduledFor: scheduled,
timePrecision
}
} catch {
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
})
if (schedule.kind === 'invalid_past') {
return {
kind: 'invalid_past',
originalRequestText: rawText,
notificationText: notificationText.length > 0 ? notificationText : body,
assigneeMemberId,
scheduledFor: schedule.scheduledFor,
timePrecision: schedule.timePrecision
}
}
return {
kind: 'parsed',
originalRequestText: rawText,
notificationText: notificationText.length > 0 ? notificationText : body,
assigneeMemberId,
scheduledFor: schedule.scheduledFor,
timePrecision: schedule.timePrecision
}
}

View File

@@ -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<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
): 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: 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.'
})
})
})

View File

@@ -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<HouseholdAssistantConfigRecord>({
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<string | null> {
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<NotificationDraftPayload, { stage: 'confirm' }>
@@ -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<NotificationDraftPayload, { stage: 'confirm' }> = {
...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,7 +690,8 @@ export function registerAdHocNotifications(options: {
return
}
if (parsed.kind === 'missing_schedule') {
if (interpretedRequest.decision === 'clarification') {
if (interpretedRequest.notificationText) {
await saveDraft(options.promptRepository, ctx, {
stage: 'await_schedule',
proposalId: createProposalId(),
@@ -594,24 +699,33 @@ export function registerAdHocNotifications(options: {
threadId: reminderContext.threadId,
creatorMemberId: reminderContext.member.id,
timezone: reminderContext.timezone,
originalRequestText: parsed.originalRequestText,
notificationText: parsed.notificationText,
assigneeMemberId: parsed.assigneeMemberId,
originalRequestText: messageText,
normalizedNotificationText: interpretedRequest.notificationText,
assigneeMemberId: interpretedRequest.assigneeMemberId,
deliveryMode: 'topic',
dmRecipientMemberIds: [],
friendlyTagAssignee: false
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<NotificationDraftPayload, { stage: 'confirm' }> = {
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: `<a href="tg://user?id=${escapeHtml(input.assignee.telegramUserId)}">${escapeHtml(input.assignee.displayName)}</a>, ${escapeHtml(input.notificationText)}`,
parseMode: 'HTML'
}
}
return {
text: escapeHtml(input.notificationText),
parseMode: 'HTML'

View File

@@ -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<BotRuntimeApp> {
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<BotRuntimeApp> {
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
notificationService: adHocNotificationService,
reminderInterpreter: adHocNotificationInterpreter,
logger: getLogger('ad-hoc-notifications')
})
}

View File

@@ -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<AdHocNotificationInterpretation>({
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
}
})
})

View File

@@ -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<AdHocNotificationInterpretation | null>
interpretSchedule(input: {
locale: 'en' | 'ru'
timezone: string
localNow: string
text: string
}): Promise<AdHocNotificationScheduleInterpretation | null>
renderDeliveryText(input: {
locale: 'en' | 'ru'
originalRequestText: string
notificationText: string
requesterDisplayName?: string | null
assigneeDisplayName?: string | null
assistantContext?: string | null
assistantTone?: string | null
}): Promise<string | null>
}
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<T>(input: {
apiKey: string
model: string
schemaName: string
schema: object
prompt: string
timeoutMs: number
}): Promise<T | null> {
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<T>(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<ReminderInterpretationResult>({
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<ReminderScheduleResult>({
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<ReminderDeliveryTextResult>({
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)
}
}
}