mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): move ad hoc notifications to llm parsing
This commit is contained in:
@@ -15,7 +15,7 @@ function dueNotification(
|
|||||||
creatorMemberId: input.creatorMemberId ?? 'creator',
|
creatorMemberId: input.creatorMemberId ?? 'creator',
|
||||||
assigneeMemberId: input.assigneeMemberId ?? 'assignee',
|
assigneeMemberId: input.assigneeMemberId ?? 'assignee',
|
||||||
originalRequestText: 'raw',
|
originalRequestText: 'raw',
|
||||||
notificationText: input.notificationText ?? 'Ping Georgiy',
|
notificationText: input.notificationText ?? 'Georgiy, time to call already.',
|
||||||
timezone: input.timezone ?? 'Asia/Tbilisi',
|
timezone: input.timezone ?? 'Asia/Tbilisi',
|
||||||
scheduledFor: input.scheduledFor ?? Temporal.Instant.from('2026-03-23T09:00:00Z'),
|
scheduledFor: input.scheduledFor ?? Temporal.Instant.from('2026-03-23T09:00:00Z'),
|
||||||
timePrecision: input.timePrecision ?? 'exact',
|
timePrecision: input.timePrecision ?? 'exact',
|
||||||
@@ -108,7 +108,7 @@ describe('createAdHocNotificationJobsHandler', () => {
|
|||||||
|
|
||||||
expect(payload.ok).toBe(true)
|
expect(payload.ok).toBe(true)
|
||||||
expect(payload.notifications[0]?.outcome).toBe('sent')
|
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'])
|
expect(sentNotifications).toEqual(['notif-1'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,9 +70,7 @@ export function createAdHocNotificationJobsHandler(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = buildTopicNotificationText({
|
const content = buildTopicNotificationText({
|
||||||
notificationText: notification.notification.notificationText,
|
notificationText: notification.notification.notificationText
|
||||||
assignee: notification.assignee,
|
|
||||||
friendlyTagAssignee: notification.notification.friendlyTagAssignee
|
|
||||||
})
|
})
|
||||||
await options.sendTopicMessage({
|
await options.sendTopicMessage({
|
||||||
householdId: notification.notification.householdId,
|
householdId: notification.notification.householdId,
|
||||||
|
|||||||
@@ -1,59 +1,32 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { Temporal } from '@household/domain'
|
import { Temporal } from '@household/domain'
|
||||||
import type { HouseholdMemberRecord } from '@household/ports'
|
|
||||||
|
|
||||||
import {
|
import { parseAdHocNotificationSchedule } from './ad-hoc-notification-parser'
|
||||||
parseAdHocNotificationRequest,
|
|
||||||
parseAdHocNotificationSchedule
|
|
||||||
} from './ad-hoc-notification-parser'
|
|
||||||
|
|
||||||
function member(
|
describe('parseAdHocNotificationSchedule', () => {
|
||||||
input: Partial<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
|
test('parses exact local datetime from structured input', () => {
|
||||||
): HouseholdMemberRecord {
|
const parsed = parseAdHocNotificationSchedule({
|
||||||
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',
|
|
||||||
timezone: 'Asia/Tbilisi',
|
timezone: 'Asia/Tbilisi',
|
||||||
locale: 'ru',
|
resolvedLocalDate: '2026-03-24',
|
||||||
members,
|
resolvedHour: 15,
|
||||||
senderMemberId: 'dima',
|
resolvedMinute: 30,
|
||||||
|
resolutionMode: 'exact',
|
||||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(parsed.kind).toBe('parsed')
|
expect(parsed.kind).toBe('parsed')
|
||||||
expect(parsed.notificationText).toContain('пошпынять Георгия')
|
|
||||||
expect(parsed.assigneeMemberId).toBe('georgiy')
|
|
||||||
expect(parsed.timePrecision).toBe('exact')
|
expect(parsed.timePrecision).toBe('exact')
|
||||||
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T11:30:00Z')
|
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T11:30:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('defaults vague tomorrow to daytime slot', () => {
|
test('keeps date-only schedules as inferred/defaulted', () => {
|
||||||
const parsed = parseAdHocNotificationRequest({
|
const parsed = parseAdHocNotificationSchedule({
|
||||||
text: 'напомни Георгию завтра про звонок',
|
|
||||||
timezone: 'Asia/Tbilisi',
|
timezone: 'Asia/Tbilisi',
|
||||||
locale: 'ru',
|
resolvedLocalDate: '2026-03-24',
|
||||||
members,
|
resolvedHour: 12,
|
||||||
senderMemberId: 'dima',
|
resolvedMinute: 0,
|
||||||
|
resolutionMode: 'date_only',
|
||||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
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')
|
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T08:00:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('requests follow-up when schedule is missing', () => {
|
test('supports fuzzy-window schedules as inferred/defaulted', () => {
|
||||||
const parsed = parseAdHocNotificationRequest({
|
const parsed = parseAdHocNotificationSchedule({
|
||||||
text: 'напомни пошпынять Георгия',
|
|
||||||
timezone: 'Asia/Tbilisi',
|
timezone: 'Asia/Tbilisi',
|
||||||
locale: 'ru',
|
resolvedLocalDate: '2026-03-24',
|
||||||
members,
|
resolvedHour: 9,
|
||||||
senderMemberId: 'dima',
|
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')
|
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(parsed.kind).toBe('missing_schedule')
|
expect(parsed.kind).toBe('missing_schedule')
|
||||||
expect(parsed.notificationText).toContain('пошпынять Георгия')
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('parseAdHocNotificationSchedule', () => {
|
test('rejects past structured schedule', () => {
|
||||||
test('rejects past schedule', () => {
|
|
||||||
const parsed = parseAdHocNotificationSchedule({
|
const parsed = parseAdHocNotificationSchedule({
|
||||||
text: 'сегодня в 10:00',
|
|
||||||
timezone: 'Asia/Tbilisi',
|
timezone: 'Asia/Tbilisi',
|
||||||
|
resolvedLocalDate: '2026-03-23',
|
||||||
|
resolvedHour: 10,
|
||||||
|
resolvedMinute: 0,
|
||||||
|
resolutionMode: 'exact',
|
||||||
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { Temporal, nowInstant, type Instant } from '@household/domain'
|
import { Temporal, nowInstant, type Instant } from '@household/domain'
|
||||||
import type { HouseholdMemberRecord } from '@household/ports'
|
|
||||||
|
|
||||||
type SupportedLocale = 'en' | 'ru'
|
import type { AdHocNotificationResolutionMode } from './openai-ad-hoc-notification-interpreter'
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedAdHocNotificationSchedule {
|
export interface ParsedAdHocNotificationSchedule {
|
||||||
kind: 'parsed' | 'missing_schedule' | 'invalid_past'
|
kind: 'parsed' | 'missing_schedule' | 'invalid_past'
|
||||||
@@ -18,233 +8,35 @@ export interface ParsedAdHocNotificationSchedule {
|
|||||||
timePrecision: 'exact' | 'date_only_defaulted' | null
|
timePrecision: 'exact' | 'date_only_defaulted' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTENT_PATTERNS = [
|
function precisionFromResolutionMode(
|
||||||
/\bremind(?: me)?(?: to)?\b/i,
|
resolutionMode: AdHocNotificationResolutionMode | null
|
||||||
/\bping me\b/i,
|
): 'exact' | 'date_only_defaulted' | null {
|
||||||
/\bnotification\b/i,
|
if (resolutionMode === 'exact') {
|
||||||
/(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu,
|
return 'exact'
|
||||||
/(?:^|[^\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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.slice(match[0].length)
|
if (resolutionMode === 'fuzzy_window' || resolutionMode === 'date_only') {
|
||||||
}
|
return 'date_only_defaulted'
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAdHocNotificationSchedule(input: {
|
export function parseAdHocNotificationSchedule(input: {
|
||||||
text: string
|
|
||||||
timezone: string
|
timezone: string
|
||||||
|
resolvedLocalDate: string | null
|
||||||
|
resolvedHour: number | null
|
||||||
|
resolvedMinute: number | null
|
||||||
|
resolutionMode: AdHocNotificationResolutionMode | null
|
||||||
now?: Instant
|
now?: Instant
|
||||||
}): ParsedAdHocNotificationSchedule {
|
}): ParsedAdHocNotificationSchedule {
|
||||||
const rawText = normalizeWhitespace(input.text)
|
const timePrecision = precisionFromResolutionMode(input.resolutionMode)
|
||||||
const referenceInstant = input.now ?? nowInstant()
|
if (
|
||||||
const date = parseDate(rawText, input.timezone, referenceInstant)
|
!input.resolvedLocalDate ||
|
||||||
const time = parseTime(rawText)
|
input.resolutionMode === null ||
|
||||||
|
input.resolutionMode === 'ambiguous' ||
|
||||||
if (!date) {
|
timePrecision === null
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: 'missing_schedule',
|
kind: 'missing_schedule',
|
||||||
scheduledFor: null,
|
scheduledFor: null,
|
||||||
@@ -252,119 +44,51 @@ export function parseAdHocNotificationSchedule(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledDateTime = Temporal.ZonedDateTime.from({
|
const hour =
|
||||||
timeZone: input.timezone,
|
input.resolutionMode === 'date_only' ? (input.resolvedHour ?? 12) : input.resolvedHour
|
||||||
year: date.date.year,
|
const minute =
|
||||||
month: date.date.month,
|
input.resolutionMode === 'date_only' ? (input.resolvedMinute ?? 0) : input.resolvedMinute
|
||||||
day: date.date.day,
|
|
||||||
hour: time?.hour ?? DAYTIME_DEFAULT_HOUR,
|
|
||||||
minute: time?.minute ?? 0,
|
|
||||||
second: 0,
|
|
||||||
millisecond: 0
|
|
||||||
}).toInstant()
|
|
||||||
|
|
||||||
if (scheduledDateTime.epochMilliseconds <= referenceInstant.epochMilliseconds) {
|
if (hour === null || minute === null) {
|
||||||
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 {
|
return {
|
||||||
kind: 'missing_schedule',
|
kind: 'missing_schedule',
|
||||||
originalRequestText: rawText,
|
|
||||||
notificationText: notificationText.length > 0 ? notificationText : body,
|
|
||||||
assigneeMemberId,
|
|
||||||
scheduledFor: null,
|
scheduledFor: null,
|
||||||
timePrecision: null
|
timePrecision: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = parseAdHocNotificationSchedule({
|
try {
|
||||||
text: [date.matchedText, time?.matchedText].filter(Boolean).join(' '),
|
const date = Temporal.PlainDate.from(input.resolvedLocalDate)
|
||||||
timezone: input.timezone,
|
const scheduled = Temporal.ZonedDateTime.from({
|
||||||
now: referenceInstant
|
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 {
|
return {
|
||||||
kind: 'invalid_past',
|
kind: 'parsed',
|
||||||
originalRequestText: rawText,
|
scheduledFor: scheduled,
|
||||||
notificationText: notificationText.length > 0 ? notificationText : body,
|
timePrecision
|
||||||
assigneeMemberId,
|
}
|
||||||
scheduledFor: schedule.scheduledFor,
|
} catch {
|
||||||
timePrecision: schedule.timePrecision
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
381
apps/bot/src/ad-hoc-notifications.test.ts
Normal file
381
apps/bot/src/ad-hoc-notifications.test.ts
Normal 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: 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { Temporal, nowInstant } from '@household/domain'
|
|||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type {
|
import type {
|
||||||
AdHocNotificationDeliveryMode,
|
AdHocNotificationDeliveryMode,
|
||||||
|
HouseholdAssistantConfigRecord,
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
HouseholdMemberRecord,
|
HouseholdMemberRecord,
|
||||||
TelegramPendingActionRepository
|
TelegramPendingActionRepository
|
||||||
@@ -10,12 +11,13 @@ import type {
|
|||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
import type { InlineKeyboardMarkup } from 'grammy/types'
|
import type { InlineKeyboardMarkup } from 'grammy/types'
|
||||||
|
|
||||||
import {
|
import { parseAdHocNotificationSchedule } from './ad-hoc-notification-parser'
|
||||||
parseAdHocNotificationRequest,
|
|
||||||
parseAdHocNotificationSchedule
|
|
||||||
} from './ad-hoc-notification-parser'
|
|
||||||
import { resolveReplyLocale } from './bot-locale'
|
import { resolveReplyLocale } from './bot-locale'
|
||||||
import type { BotLocale } from './i18n'
|
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 = 'ad_hoc_notification' as const
|
||||||
const AD_HOC_NOTIFICATION_ACTION_TTL_MS = 30 * 60_000
|
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_DRAFT_PREFIX = 'adhocnotif:canceldraft:'
|
||||||
const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:'
|
const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:'
|
||||||
const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:'
|
const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:'
|
||||||
const AD_HOC_NOTIFICATION_FRIENDLY_PREFIX = 'adhocnotif:friendly:'
|
|
||||||
const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:'
|
const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:'
|
||||||
|
|
||||||
type NotificationDraftPayload =
|
type NotificationDraftPayload =
|
||||||
@@ -35,11 +36,10 @@ type NotificationDraftPayload =
|
|||||||
creatorMemberId: string
|
creatorMemberId: string
|
||||||
timezone: string
|
timezone: string
|
||||||
originalRequestText: string
|
originalRequestText: string
|
||||||
notificationText: string
|
normalizedNotificationText: string
|
||||||
assigneeMemberId: string | null
|
assigneeMemberId: string | null
|
||||||
deliveryMode: AdHocNotificationDeliveryMode
|
deliveryMode: AdHocNotificationDeliveryMode
|
||||||
dmRecipientMemberIds: readonly string[]
|
dmRecipientMemberIds: readonly string[]
|
||||||
friendlyTagAssignee: boolean
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
stage: 'confirm'
|
stage: 'confirm'
|
||||||
@@ -49,13 +49,13 @@ type NotificationDraftPayload =
|
|||||||
creatorMemberId: string
|
creatorMemberId: string
|
||||||
timezone: string
|
timezone: string
|
||||||
originalRequestText: string
|
originalRequestText: string
|
||||||
notificationText: string
|
normalizedNotificationText: string
|
||||||
|
renderedNotificationText: string
|
||||||
assigneeMemberId: string | null
|
assigneeMemberId: string | null
|
||||||
scheduledForIso: string
|
scheduledForIso: string
|
||||||
timePrecision: 'exact' | 'date_only_defaulted'
|
timePrecision: 'exact' | 'date_only_defaulted'
|
||||||
deliveryMode: AdHocNotificationDeliveryMode
|
deliveryMode: AdHocNotificationDeliveryMode
|
||||||
dmRecipientMemberIds: readonly string[]
|
dmRecipientMemberIds: readonly string[]
|
||||||
friendlyTagAssignee: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReminderTopicContext {
|
interface ReminderTopicContext {
|
||||||
@@ -65,6 +65,32 @@ interface ReminderTopicContext {
|
|||||||
member: HouseholdMemberRecord
|
member: HouseholdMemberRecord
|
||||||
members: readonly HouseholdMemberRecord[]
|
members: readonly HouseholdMemberRecord[]
|
||||||
timezone: string
|
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 {
|
function createProposalId(): string {
|
||||||
@@ -152,15 +178,14 @@ function notificationSummaryText(input: {
|
|||||||
return [
|
return [
|
||||||
'Запланировать напоминание?',
|
'Запланировать напоминание?',
|
||||||
'',
|
'',
|
||||||
`Текст: ${input.payload.notificationText}`,
|
`Текст напоминания: ${input.payload.renderedNotificationText}`,
|
||||||
`Когда: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`,
|
`Когда: ${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)}`,
|
`Куда: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`,
|
||||||
assignee ? `Ответственный: ${assignee.displayName}` : null,
|
assignee ? `Ответственный: ${assignee.displayName}` : null,
|
||||||
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0
|
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0
|
||||||
? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
|
? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
|
||||||
: null,
|
: null,
|
||||||
assignee ? `Дружелюбный тег: ${input.payload.friendlyTagAssignee ? 'вкл' : 'выкл'}` : null,
|
|
||||||
'',
|
'',
|
||||||
'Подтвердите или измените настройки ниже.'
|
'Подтвердите или измените настройки ниже.'
|
||||||
]
|
]
|
||||||
@@ -171,15 +196,14 @@ function notificationSummaryText(input: {
|
|||||||
return [
|
return [
|
||||||
'Schedule this notification?',
|
'Schedule this notification?',
|
||||||
'',
|
'',
|
||||||
`Text: ${input.payload.notificationText}`,
|
`Reminder text: ${input.payload.renderedNotificationText}`,
|
||||||
`When: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`,
|
`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)}`,
|
`Delivery: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`,
|
||||||
assignee ? `Assignee: ${assignee.displayName}` : null,
|
assignee ? `Assignee: ${assignee.displayName}` : null,
|
||||||
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0
|
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0
|
||||||
? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
|
? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
|
||||||
: null,
|
: null,
|
||||||
assignee ? `Friendly tag: ${input.payload.friendlyTagAssignee ? 'on' : 'off'}` : null,
|
|
||||||
'',
|
'',
|
||||||
'Confirm or adjust below.'
|
'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') {
|
if (payload.deliveryMode === 'dm_selected') {
|
||||||
const eligibleMembers = members.filter((member) => member.status === 'active')
|
const eligibleMembers = members.filter((member) => member.status === 'active')
|
||||||
for (const member of eligibleMembers) {
|
for (const member of eligibleMembers) {
|
||||||
@@ -331,7 +346,7 @@ async function resolveReminderTopicContext(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const [locale, member, members, settings] = await Promise.all([
|
const [locale, member, members, settings, assistantConfig] = await Promise.all([
|
||||||
resolveReplyLocale({
|
resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
repository,
|
repository,
|
||||||
@@ -339,7 +354,14 @@ async function resolveReminderTopicContext(
|
|||||||
}),
|
}),
|
||||||
repository.getHouseholdMember(binding.householdId, telegramUserId),
|
repository.getHouseholdMember(binding.householdId, telegramUserId),
|
||||||
repository.listHouseholdMembers(binding.householdId),
|
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) {
|
if (!member) {
|
||||||
@@ -352,7 +374,9 @@ async function resolveReminderTopicContext(
|
|||||||
threadId,
|
threadId,
|
||||||
member,
|
member,
|
||||||
members,
|
members,
|
||||||
timezone: settings.timezone
|
timezone: settings.timezone,
|
||||||
|
assistantContext: assistantConfig.assistantContext,
|
||||||
|
assistantTone: assistantConfig.assistantTone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +421,32 @@ export function registerAdHocNotifications(options: {
|
|||||||
householdConfigurationRepository: HouseholdConfigurationRepository
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
promptRepository: TelegramPendingActionRepository
|
promptRepository: TelegramPendingActionRepository
|
||||||
notificationService: AdHocNotificationService
|
notificationService: AdHocNotificationService
|
||||||
|
reminderInterpreter: AdHocNotificationInterpreter | undefined
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): 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(
|
async function showDraftConfirmation(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
draft: Extract<NotificationDraftPayload, { stage: 'confirm' }>
|
draft: Extract<NotificationDraftPayload, { stage: 'confirm' }>
|
||||||
@@ -523,17 +571,48 @@ export function registerAdHocNotifications(options: {
|
|||||||
const existingDraft = await loadDraft(options.promptRepository, ctx)
|
const existingDraft = await loadDraft(options.promptRepository, ctx)
|
||||||
if (existingDraft && existingDraft.threadId === reminderContext.threadId) {
|
if (existingDraft && existingDraft.threadId === reminderContext.threadId) {
|
||||||
if (existingDraft.stage === 'await_schedule') {
|
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({
|
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') {
|
if (schedule.kind === 'missing_schedule') {
|
||||||
await replyInTopic(
|
await replyInTopic(
|
||||||
ctx,
|
ctx,
|
||||||
reminderContext.locale === 'ru'
|
reminderContext.locale === 'ru'
|
||||||
? 'Нужны хотя бы день или дата. Например: «завтра», «24.03», «2026-03-24 18:30».'
|
? 'Нужны дата или понятное время. Например: «завтра утром», «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".'
|
: 'I still need a date or a clear time. For example: "tomorrow morning", "2026-03-24", or "2026-03-24 18:30".'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -548,9 +627,21 @@ export function registerAdHocNotifications(options: {
|
|||||||
return
|
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' }> = {
|
const confirmPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||||
...existingDraft,
|
...existingDraft,
|
||||||
stage: 'confirm',
|
stage: 'confirm',
|
||||||
|
renderedNotificationText,
|
||||||
scheduledForIso: schedule.scheduledFor!.toString(),
|
scheduledForIso: schedule.scheduledFor!.toString(),
|
||||||
timePrecision: schedule.timePrecision!
|
timePrecision: schedule.timePrecision!
|
||||||
}
|
}
|
||||||
@@ -563,20 +654,33 @@ export function registerAdHocNotifications(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseAdHocNotificationRequest({
|
if (!options.reminderInterpreter) {
|
||||||
|
await replyInTopic(ctx, unavailableReply(reminderContext.locale))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const interpretedRequest = await options.reminderInterpreter.interpretRequest({
|
||||||
text: messageText,
|
text: messageText,
|
||||||
timezone: reminderContext.timezone,
|
timezone: reminderContext.timezone,
|
||||||
locale: reminderContext.locale,
|
locale: reminderContext.locale,
|
||||||
members: reminderContext.members,
|
localNow: localNowText(reminderContext.timezone),
|
||||||
senderMemberId: reminderContext.member.id
|
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()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.notificationText || parsed.notificationText.length === 0) {
|
if (!interpretedRequest.notificationText || interpretedRequest.notificationText.length === 0) {
|
||||||
await replyInTopic(
|
await replyInTopic(
|
||||||
ctx,
|
ctx,
|
||||||
reminderContext.locale === 'ru'
|
reminderContext.locale === 'ru'
|
||||||
@@ -586,32 +690,42 @@ export function registerAdHocNotifications(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === 'missing_schedule') {
|
if (interpretedRequest.decision === 'clarification') {
|
||||||
await saveDraft(options.promptRepository, ctx, {
|
if (interpretedRequest.notificationText) {
|
||||||
stage: 'await_schedule',
|
await saveDraft(options.promptRepository, ctx, {
|
||||||
proposalId: createProposalId(),
|
stage: 'await_schedule',
|
||||||
householdId: reminderContext.householdId,
|
proposalId: createProposalId(),
|
||||||
threadId: reminderContext.threadId,
|
householdId: reminderContext.householdId,
|
||||||
creatorMemberId: reminderContext.member.id,
|
threadId: reminderContext.threadId,
|
||||||
timezone: reminderContext.timezone,
|
creatorMemberId: reminderContext.member.id,
|
||||||
originalRequestText: parsed.originalRequestText,
|
timezone: reminderContext.timezone,
|
||||||
notificationText: parsed.notificationText,
|
originalRequestText: messageText,
|
||||||
assigneeMemberId: parsed.assigneeMemberId,
|
normalizedNotificationText: interpretedRequest.notificationText,
|
||||||
deliveryMode: 'topic',
|
assigneeMemberId: interpretedRequest.assigneeMemberId,
|
||||||
dmRecipientMemberIds: [],
|
deliveryMode: 'topic',
|
||||||
friendlyTagAssignee: false
|
dmRecipientMemberIds: []
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await replyInTopic(
|
await replyInTopic(
|
||||||
ctx,
|
ctx,
|
||||||
reminderContext.locale === 'ru'
|
interpretedRequest.clarificationQuestion ??
|
||||||
? 'Когда напомнить? Подойдёт свободная форма, например: «завтра», «завтра в 15:00», «24.03 18:30».'
|
(reminderContext.locale === 'ru'
|
||||||
: 'When should I remind? Free-form is fine, for example: "tomorrow", "tomorrow 15:00", or "2026-03-24 18:30".'
|
? 'Когда напомнить? Подойдёт свободная форма, например: «завтра утром», «завтра в 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
|
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(
|
await replyInTopic(
|
||||||
ctx,
|
ctx,
|
||||||
reminderContext.locale === 'ru'
|
reminderContext.locale === 'ru'
|
||||||
@@ -621,6 +735,28 @@ export function registerAdHocNotifications(options: {
|
|||||||
return
|
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' }> = {
|
const draft: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
|
||||||
stage: 'confirm',
|
stage: 'confirm',
|
||||||
proposalId: createProposalId(),
|
proposalId: createProposalId(),
|
||||||
@@ -628,14 +764,14 @@ export function registerAdHocNotifications(options: {
|
|||||||
threadId: reminderContext.threadId,
|
threadId: reminderContext.threadId,
|
||||||
creatorMemberId: reminderContext.member.id,
|
creatorMemberId: reminderContext.member.id,
|
||||||
timezone: reminderContext.timezone,
|
timezone: reminderContext.timezone,
|
||||||
originalRequestText: parsed.originalRequestText,
|
originalRequestText: messageText,
|
||||||
notificationText: parsed.notificationText,
|
normalizedNotificationText: interpretedRequest.notificationText,
|
||||||
assigneeMemberId: parsed.assigneeMemberId,
|
renderedNotificationText,
|
||||||
scheduledForIso: parsed.scheduledFor!.toString(),
|
assigneeMemberId: interpretedRequest.assigneeMemberId,
|
||||||
timePrecision: parsed.timePrecision!,
|
scheduledForIso: parsedSchedule.scheduledFor!.toString(),
|
||||||
|
timePrecision: parsedSchedule.timePrecision!,
|
||||||
deliveryMode: 'topic',
|
deliveryMode: 'topic',
|
||||||
dmRecipientMemberIds: [],
|
dmRecipientMemberIds: []
|
||||||
friendlyTagAssignee: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveDraft(options.promptRepository, ctx, draft)
|
await saveDraft(options.promptRepository, ctx, draft)
|
||||||
@@ -670,14 +806,14 @@ export function registerAdHocNotifications(options: {
|
|||||||
householdId: payload.householdId,
|
householdId: payload.householdId,
|
||||||
creatorMemberId: payload.creatorMemberId,
|
creatorMemberId: payload.creatorMemberId,
|
||||||
originalRequestText: payload.originalRequestText,
|
originalRequestText: payload.originalRequestText,
|
||||||
notificationText: payload.notificationText,
|
notificationText: payload.renderedNotificationText,
|
||||||
timezone: payload.timezone,
|
timezone: payload.timezone,
|
||||||
scheduledFor: Temporal.Instant.from(payload.scheduledForIso),
|
scheduledFor: Temporal.Instant.from(payload.scheduledForIso),
|
||||||
timePrecision: payload.timePrecision,
|
timePrecision: payload.timePrecision,
|
||||||
deliveryMode: payload.deliveryMode,
|
deliveryMode: payload.deliveryMode,
|
||||||
assigneeMemberId: payload.assigneeMemberId,
|
assigneeMemberId: payload.assigneeMemberId,
|
||||||
dmRecipientMemberIds: payload.dmRecipientMemberIds,
|
dmRecipientMemberIds: payload.dmRecipientMemberIds,
|
||||||
friendlyTagAssignee: payload.friendlyTagAssignee,
|
friendlyTagAssignee: false,
|
||||||
sourceTelegramChatId: ctx.chat?.id?.toString() ?? null,
|
sourceTelegramChatId: ctx.chat?.id?.toString() ?? null,
|
||||||
sourceTelegramThreadId: payload.threadId
|
sourceTelegramThreadId: payload.threadId
|
||||||
})
|
})
|
||||||
@@ -769,22 +905,6 @@ export function registerAdHocNotifications(options: {
|
|||||||
return
|
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)) {
|
if (data.startsWith(AD_HOC_NOTIFICATION_MEMBER_PREFIX)) {
|
||||||
const rest = data.slice(AD_HOC_NOTIFICATION_MEMBER_PREFIX.length)
|
const rest = data.slice(AD_HOC_NOTIFICATION_MEMBER_PREFIX.length)
|
||||||
const separatorIndex = rest.indexOf(':')
|
const separatorIndex = rest.indexOf(':')
|
||||||
@@ -863,24 +983,10 @@ export function registerAdHocNotifications(options: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTopicNotificationText(input: {
|
export function buildTopicNotificationText(input: { notificationText: string }): {
|
||||||
notificationText: string
|
|
||||||
assignee?: {
|
|
||||||
displayName: string
|
|
||||||
telegramUserId: string
|
|
||||||
} | null
|
|
||||||
friendlyTagAssignee: boolean
|
|
||||||
}): {
|
|
||||||
text: string
|
text: string
|
||||||
parseMode: 'HTML'
|
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 {
|
return {
|
||||||
text: escapeHtml(input.notificationText),
|
text: escapeHtml(input.notificationText),
|
||||||
parseMode: 'HTML'
|
parseMode: 'HTML'
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||||
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
|
import { createNbgExchangeRateProvider } from './nbg-exchange-rates'
|
||||||
import { createOpenAiChatAssistant } from './openai-chat-assistant'
|
import { createOpenAiChatAssistant } from './openai-chat-assistant'
|
||||||
|
import { createOpenAiAdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter'
|
||||||
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
@@ -152,6 +153,12 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
runtime.openaiApiKey,
|
runtime.openaiApiKey,
|
||||||
runtime.purchaseParserModel
|
runtime.purchaseParserModel
|
||||||
)
|
)
|
||||||
|
const adHocNotificationInterpreter = createOpenAiAdHocNotificationInterpreter({
|
||||||
|
apiKey: runtime.openaiApiKey,
|
||||||
|
parserModel: runtime.purchaseParserModel,
|
||||||
|
rendererModel: runtime.assistantModel,
|
||||||
|
timeoutMs: runtime.assistantTimeoutMs
|
||||||
|
})
|
||||||
const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore(
|
const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore(
|
||||||
runtime.assistantMemoryMaxTurns
|
runtime.assistantMemoryMaxTurns
|
||||||
)
|
)
|
||||||
@@ -403,6 +410,7 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
|
|||||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
promptRepository: telegramPendingActionRepositoryClient.repository,
|
promptRepository: telegramPendingActionRepositoryClient.repository,
|
||||||
notificationService: adHocNotificationService,
|
notificationService: adHocNotificationService,
|
||||||
|
reminderInterpreter: adHocNotificationInterpreter,
|
||||||
logger: getLogger('ad-hoc-notifications')
|
logger: getLogger('ad-hoc-notifications')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
251
apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts
Normal file
251
apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
490
apps/bot/src/openai-ad-hoc-notification-interpreter.ts
Normal file
490
apps/bot/src/openai-ad-hoc-notification-interpreter.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user