diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 161f0c2..d715fe8 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -15,6 +15,7 @@ export interface BotRuntimeConfig { reminderJobsEnabled: boolean openaiApiKey?: string parserModel: string + purchaseParserModel: string } function parsePort(raw: string | undefined): number { @@ -103,7 +104,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu miniAppAuthEnabled, schedulerOidcAllowedEmails, reminderJobsEnabled, - parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' + parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini', + purchaseParserModel: + env.PURCHASE_PARSER_MODEL?.trim() || env.PARSER_MODEL?.trim() || 'gpt-5-mini' } if (databaseUrl !== undefined) { diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 1e787a9..bd7a078 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -164,9 +164,27 @@ export const enBotTranslations: BotTranslationCatalog = { }, purchase: { sharedPurchaseFallback: 'shared purchase', - recorded: (summary) => `Recorded purchase: ${summary}`, - savedForReview: (summary) => `Saved for review: ${summary}`, - parseFailed: "Saved for review: I couldn't parse this purchase yet." + proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`, + clarification: (question) => question, + clarificationMissingAmountAndCurrency: + 'What amount and currency should I record for this shared purchase?', + clarificationMissingAmount: 'What amount should I record for this shared purchase?', + clarificationMissingCurrency: 'Which currency was this purchase in?', + clarificationMissingItem: 'What exactly was purchased?', + clarificationLowConfidence: + 'I am not confident I understood this. Please restate the shared purchase with item, amount, and currency.', + confirmButton: 'Confirm', + cancelButton: 'Cancel', + confirmed: (summary) => `Purchase confirmed: ${summary}`, + cancelled: (summary) => `Purchase proposal cancelled: ${summary}`, + confirmedToast: 'Purchase confirmed.', + cancelledToast: 'Purchase cancelled.', + alreadyConfirmed: 'This purchase was already confirmed.', + alreadyCancelled: 'This purchase was already cancelled.', + notYourProposal: 'Only the original sender can confirm or cancel this purchase.', + proposalUnavailable: 'This purchase proposal is no longer available.', + parseFailed: + "I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency." }, payments: { topicMissing: diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index e60a04a..a3c8afb 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -167,9 +167,27 @@ export const ruBotTranslations: BotTranslationCatalog = { }, purchase: { sharedPurchaseFallback: 'общая покупка', - recorded: (summary) => `Покупка сохранена: ${summary}`, - savedForReview: (summary) => `Сохранено на проверку: ${summary}`, - parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.' + proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`, + clarification: (question) => question, + clarificationMissingAmountAndCurrency: + 'Какую сумму и валюту нужно записать для этой общей покупки?', + clarificationMissingAmount: 'Какую сумму нужно записать для этой общей покупки?', + clarificationMissingCurrency: 'В какой валюте была эта покупка?', + clarificationMissingItem: 'Что именно было куплено?', + clarificationLowConfidence: + 'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.', + confirmButton: 'Подтвердить', + cancelButton: 'Отменить', + confirmed: (summary) => `Покупка подтверждена: ${summary}`, + cancelled: (summary) => `Предложение покупки отменено: ${summary}`, + confirmedToast: 'Покупка подтверждена.', + cancelledToast: 'Покупка отменена.', + alreadyConfirmed: 'Эта покупка уже подтверждена.', + alreadyCancelled: 'Это предложение покупки уже отменено.', + notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.', + proposalUnavailable: 'Это предложение покупки уже недоступно.', + parseFailed: + 'Пока не удалось распознать это как общую покупку. Напишите предмет, сумму и валюту явно.' }, payments: { topicMissing: diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 6e861b2..499db09 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -187,8 +187,23 @@ export interface BotTranslationCatalog { } purchase: { sharedPurchaseFallback: string - recorded: (summary: string) => string - savedForReview: (summary: string) => string + proposal: (summary: string) => string + clarification: (question: string) => string + clarificationMissingAmountAndCurrency: string + clarificationMissingAmount: string + clarificationMissingCurrency: string + clarificationMissingItem: string + clarificationLowConfidence: string + confirmButton: string + cancelButton: string + confirmed: (summary: string) => string + cancelled: (summary: string) => string + confirmedToast: string + cancelledToast: string + alreadyConfirmed: string + alreadyCancelled: string + notYourProposal: string + proposalUnavailable: string parseFailed: string } payments: { diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 75ff45e..1e44fff 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -25,7 +25,7 @@ import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' import { registerHouseholdSetupCommands } from './household-setup' -import { createOpenAiParserFallback } from './openai-parser-fallback' +import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter' import { createPurchaseMessageRepository, registerConfiguredPurchaseTopicIngestion @@ -184,16 +184,19 @@ if (telegramPendingActionRepositoryClient) { if (runtime.databaseUrl && householdConfigurationRepositoryClient) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) shutdownTasks.push(purchaseRepositoryClient.close) - const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel) + const purchaseInterpreter = createOpenAiPurchaseInterpreter( + runtime.openaiApiKey, + runtime.purchaseParserModel + ) registerConfiguredPurchaseTopicIngestion( bot, householdConfigurationRepositoryClient.repository, purchaseRepositoryClient.repository, { - ...(llmFallback + ...(purchaseInterpreter ? { - llmFallback + interpreter: purchaseInterpreter } : {}), logger: getLogger('purchase-ingestion') diff --git a/apps/bot/src/openai-purchase-interpreter.ts b/apps/bot/src/openai-purchase-interpreter.ts new file mode 100644 index 0000000..ebc036a --- /dev/null +++ b/apps/bot/src/openai-purchase-interpreter.ts @@ -0,0 +1,173 @@ +export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase' + +export interface PurchaseInterpretation { + decision: PurchaseInterpretationDecision + amountMinor: bigint | null + currency: 'GEL' | 'USD' | null + itemDescription: string | null + confidence: number + parserMode: 'llm' + clarificationQuestion: string | null +} + +export type PurchaseMessageInterpreter = ( + rawText: string, + options: { + defaultCurrency: 'GEL' | 'USD' + } +) => Promise + +interface OpenAiStructuredResult { + decision: PurchaseInterpretationDecision + amountMinor: string | null + currency: 'GEL' | 'USD' | null + itemDescription: string | null + confidence: number + clarificationQuestion: string | null +} + +function asOptionalBigInt(value: string | null): bigint | null { + if (value === null || !/^[0-9]+$/.test(value)) { + return null + } + + const parsed = BigInt(value) + return parsed > 0n ? parsed : null +} + +function normalizeOptionalText(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed && trimmed.length > 0 ? trimmed : null +} + +function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null { + return value === 'GEL' || value === 'USD' ? value : null +} + +export function createOpenAiPurchaseInterpreter( + apiKey: string | undefined, + model: string +): PurchaseMessageInterpreter | undefined { + if (!apiKey) { + return undefined + } + + return async (rawText, options) => { + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model, + input: [ + { + role: 'system', + content: [ + 'You classify a single Telegram message from a household shared-purchases topic.', + 'Decide whether the message is a real shared purchase, needs clarification, or is not a shared purchase at all.', + `The household default currency is ${options.defaultCurrency}, but do not assume that omitted currency means ${options.defaultCurrency}.`, + 'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.', + 'Return a clarification question in the same language as the user message when clarification is needed.', + 'Return only JSON that matches the schema.' + ].join(' ') + }, + { + role: 'user', + content: rawText + } + ], + text: { + format: { + type: 'json_schema', + name: 'purchase_interpretation', + schema: { + type: 'object', + additionalProperties: false, + properties: { + decision: { + type: 'string', + enum: ['purchase', 'clarification', 'not_purchase'] + }, + amountMinor: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, + currency: { + anyOf: [ + { + type: 'string', + enum: ['GEL', 'USD'] + }, + { type: 'null' } + ] + }, + itemDescription: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 100 + }, + clarificationQuestion: { + anyOf: [{ type: 'string' }, { type: 'null' }] + } + }, + required: [ + 'decision', + 'amountMinor', + 'currency', + 'itemDescription', + 'confidence', + 'clarificationQuestion' + ] + } + } + } + }) + }) + + if (!response.ok) { + return null + } + + const payload = (await response.json()) as { + output_text?: string + } + + if (!payload.output_text) { + return null + } + + let parsedJson: OpenAiStructuredResult + try { + parsedJson = JSON.parse(payload.output_text) as OpenAiStructuredResult + } catch { + return null + } + + if ( + parsedJson.decision !== 'purchase' && + parsedJson.decision !== 'clarification' && + parsedJson.decision !== 'not_purchase' + ) { + return null + } + + const clarificationQuestion = normalizeOptionalText(parsedJson.clarificationQuestion) + if (parsedJson.decision === 'clarification' && !clarificationQuestion) { + return null + } + + return { + decision: parsedJson.decision, + amountMinor: asOptionalBigInt(parsedJson.amountMinor), + currency: normalizeCurrency(parsedJson.currency), + itemDescription: normalizeOptionalText(parsedJson.itemDescription), + confidence: Math.max(0, Math.min(100, Math.round(parsedJson.confidence))), + parserMode: 'llm', + clarificationQuestion + } + } +} diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index fd857e6..7d32cff 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -64,6 +64,51 @@ function purchaseUpdate(text: string) { } } +function callbackUpdate(data: string, fromId = 10002) { + return { + update_id: 1002, + callback_query: { + id: 'callback-1', + from: { + id: fromId, + is_bot: false, + first_name: 'Mia' + }, + chat_instance: 'instance-1', + data, + message: { + message_id: 77, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + text: 'placeholder' + } + } + } +} + +function createTestBot() { + const bot = createTelegramBot('000000:test-token') + + 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 + } + + return bot +} + describe('extractPurchaseTopicCandidate', () => { test('returns record when message belongs to configured topic', () => { const record = extractPurchaseTopicCandidate(candidate(), config) @@ -127,93 +172,103 @@ describe('resolveConfiguredPurchaseTopicRecord', () => { }) describe('buildPurchaseAcknowledgement', () => { - test('returns parsed acknowledgement with amount summary', () => { + test('returns proposal acknowledgement for a likely purchase', () => { const result = buildPurchaseAcknowledgement({ - status: 'created', - processingStatus: 'parsed', + status: 'pending_confirmation', + purchaseMessageId: 'proposal-1', parsedAmountMinor: 3000n, parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'rules' + parserMode: 'llm' }) - expect(result).toBe('Recorded purchase: toilet paper - 30.00 GEL') + expect(result).toBe( + 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.' + ) }) - test('returns review acknowledgement when parsing needs review', () => { + test('returns explicit clarification text from the interpreter', () => { const result = buildPurchaseAcknowledgement({ - status: 'created', - processingStatus: 'needs_review', + status: 'clarification_needed', + purchaseMessageId: 'proposal-2', + clarificationQuestion: 'Which currency was this purchase in?', parsedAmountMinor: 3000n, - parsedCurrency: 'GEL', - parsedItemDescription: 'shared purchase', - parserConfidence: 78, - parserMode: 'rules' + parsedCurrency: null, + parsedItemDescription: 'toilet paper', + parserConfidence: 61, + parserMode: 'llm' }) - expect(result).toBe('Saved for review: shared purchase - 30.00 GEL') + expect(result).toBe('Which currency was this purchase in?') }) - test('returns parse failure acknowledgement without guessed values', () => { + test('returns fallback clarification when the interpreter question is missing', () => { const result = buildPurchaseAcknowledgement({ - status: 'created', - processingStatus: 'parse_failed', + status: 'clarification_needed', + purchaseMessageId: 'proposal-3', + clarificationQuestion: null, parsedAmountMinor: null, parsedCurrency: null, - parsedItemDescription: null, - parserConfidence: null, - parserMode: null + parsedItemDescription: 'toilet paper', + parserConfidence: 42, + parserMode: 'llm' }) - expect(result).toBe("Saved for review: I couldn't parse this purchase yet.") + expect(result).toBe('What amount and currency should I record for this shared purchase?') }) - test('does not acknowledge duplicates', () => { + test('returns parse failure acknowledgement without guessing values', () => { + const result = buildPurchaseAcknowledgement({ + status: 'parse_failed', + purchaseMessageId: 'proposal-4' + }) + + expect(result).toBe( + "I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency." + ) + }) + + test('does not acknowledge duplicates or non-purchase chatter', () => { expect( buildPurchaseAcknowledgement({ status: 'duplicate' }) ).toBeNull() + + expect( + buildPurchaseAcknowledgement({ + status: 'ignored_not_purchase', + purchaseMessageId: 'proposal-5' + }) + ).toBeNull() }) - test('returns Russian acknowledgement when requested', () => { + test('returns Russian proposal text when requested', () => { const result = buildPurchaseAcknowledgement( { - status: 'created', - processingStatus: 'parsed', + status: 'pending_confirmation', + purchaseMessageId: 'proposal-6', parsedAmountMinor: 3000n, parsedCurrency: 'GEL', parsedItemDescription: 'туалетная бумага', parserConfidence: 92, - parserMode: 'rules' + parserMode: 'llm' }, 'ru' ) - expect(result).toBe('Покупка сохранена: туалетная бумага - 30.00 GEL') + expect(result).toBe( + 'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.' + ) }) }) describe('registerPurchaseTopicIngestion', () => { - test('replies in-topic after a parsed purchase is recorded', async () => { - const bot = createTelegramBot('000000:test-token') + test('replies in-topic with a proposal and buttons for a likely purchase', async () => { + const bot = createTestBot() 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 }) @@ -234,14 +289,20 @@ describe('registerPurchaseTopicIngestion', () => { const repository: PurchaseMessageIngestionRepository = { async save() { return { - status: 'created', - processingStatus: 'parsed', + status: 'pending_confirmation', + purchaseMessageId: 'proposal-1', parsedAmountMinor: 3000n, parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', parserConfidence: 92, - parserMode: 'rules' + parserMode: 'llm' } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') } } @@ -255,28 +316,28 @@ describe('registerPurchaseTopicIngestion', () => { reply_parameters: { message_id: 55 }, - text: 'Recorded purchase: toilet paper - 30.00 GEL' + text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Confirm', + callback_data: 'purchase:confirm:proposal-1' + }, + { + text: 'Cancel', + callback_data: 'purchase:cancel:proposal-1' + } + ] + ] + } }) }) - test('does not reply for duplicate deliveries', async () => { - const bot = createTelegramBot('000000:test-token') + test('replies with a clarification question for ambiguous purchases', async () => { + const bot = createTestBot() 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 }) @@ -297,14 +358,240 @@ describe('registerPurchaseTopicIngestion', () => { const repository: PurchaseMessageIngestionRepository = { async save() { return { - status: 'duplicate' + status: 'clarification_needed', + purchaseMessageId: 'proposal-1', + clarificationQuestion: 'Which currency was this purchase in?', + parsedAmountMinor: 3000n, + parsedCurrency: null, + parsedItemDescription: 'toilet paper', + parserConfidence: 52, + parserMode: 'llm' } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(purchaseUpdate('Bought toilet paper for 30') as never) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + text: 'Which currency was this purchase in?' + }) + }) + + test('does not reply for duplicate deliveries or non-purchase chatter', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let saveCall = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async save() { + saveCall += 1 + return saveCall === 1 + ? { + status: 'duplicate' as const + } + : { + status: 'ignored_not_purchase' as const, + purchaseMessageId: 'proposal-1' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') } } registerPurchaseTopicIngestion(bot, config, repository) await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never) + await bot.handleUpdate(purchaseUpdate('This is not a purchase') as never) expect(calls).toHaveLength(0) }) + + test('confirms a pending proposal and edits the bot message', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async save() { + return { + status: 'pending_confirmation', + purchaseMessageId: 'proposal-1', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'toilet paper', + parserConfidence: 92, + parserMode: 'llm' + } + }, + async confirm() { + return { + status: 'confirmed' as const, + purchaseMessageId: 'proposal-1', + householdId: config.householdId, + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL' as const, + parsedItemDescription: 'toilet paper', + parserConfidence: 92, + parserMode: 'llm' as const + } + }, + async cancel() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Purchase confirmed.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: Number(config.householdChatId), + message_id: 77, + text: 'Purchase confirmed: toilet paper - 30.00 GEL', + reply_markup: { + inline_keyboard: [] + } + } + }) + }) + + test('handles duplicate confirm callbacks idempotently', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async save() { + throw new Error('not used') + }, + async confirm() { + return { + status: 'already_confirmed' as const, + purchaseMessageId: 'proposal-1', + householdId: config.householdId, + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL' as const, + parsedItemDescription: 'toilet paper', + parserConfidence: 92, + parserMode: 'llm' as const + } + }, + async cancel() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never) + + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'This purchase was already confirmed.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + text: 'Purchase confirmed: toilet paper - 30.00 GEL' + } + }) + }) + + test('cancels a pending proposal and edits the bot message', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async save() { + throw new Error('not used') + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + return { + status: 'cancelled' as const, + purchaseMessageId: 'proposal-1', + householdId: config.householdId, + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL' as const, + parsedItemDescription: 'toilet paper', + parserConfidence: 92, + parserMode: 'llm' as const + } + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(callbackUpdate('purchase:cancel:proposal-1') as never) + + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Purchase cancelled.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + text: 'Purchase proposal cancelled: toilet paper - 30.00 GEL' + } + }) + }) }) diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 5c1d06a..170b7c4 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -1,4 +1,3 @@ -import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain' import { and, eq } from 'drizzle-orm' import type { Bot, Context } from 'grammy' @@ -10,6 +9,60 @@ import type { import { createDbClient, schema } from '@household/db' import { getBotTranslations, type BotLocale } from './i18n' +import type { + PurchaseInterpretation, + PurchaseMessageInterpreter +} from './openai-purchase-interpreter' + +const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:' +const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:' +const MIN_PROPOSAL_CONFIDENCE = 70 + +type StoredPurchaseProcessingStatus = + | 'pending_confirmation' + | 'clarification_needed' + | 'ignored_not_purchase' + | 'parse_failed' + | 'confirmed' + | 'cancelled' + | 'parsed' + | 'needs_review' + +interface StoredPurchaseMessageRow { + id: string + householdId: string + senderTelegramUserId: string + parsedAmountMinor: bigint | null + parsedCurrency: 'GEL' | 'USD' | null + parsedItemDescription: string | null + parserConfidence: number | null + parserMode: 'llm' | null + processingStatus: StoredPurchaseProcessingStatus +} + +interface PurchaseProposalFields { + parsedAmountMinor: bigint | null + parsedCurrency: 'GEL' | 'USD' | null + parsedItemDescription: string | null + parserConfidence: number | null + parserMode: 'llm' | null +} + +interface PurchaseClarificationResult extends PurchaseProposalFields { + status: 'clarification_needed' + purchaseMessageId: string + clarificationQuestion: string | null +} + +interface PurchasePendingConfirmationResult extends PurchaseProposalFields { + status: 'pending_confirmation' + purchaseMessageId: string + parsedAmountMinor: bigint + parsedCurrency: 'GEL' | 'USD' + parsedItemDescription: string + parserConfidence: number + parserMode: 'llm' +} export interface PurchaseTopicIngestionConfig { householdId: string @@ -32,28 +85,247 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate { householdId: string } -export type PurchaseMessageProcessingStatus = 'parsed' | 'needs_review' | 'parse_failed' - export type PurchaseMessageIngestionResult = | { status: 'duplicate' } | { - status: 'created' - processingStatus: PurchaseMessageProcessingStatus - parsedAmountMinor: bigint | null - parsedCurrency: 'GEL' | 'USD' | null - parsedItemDescription: string | null - parserConfidence: number | null - parserMode: 'rules' | 'llm' | null + status: 'ignored_not_purchase' + purchaseMessageId: string + } + | PurchaseClarificationResult + | PurchasePendingConfirmationResult + | { + status: 'parse_failed' + purchaseMessageId: string + } + +export type PurchaseProposalActionResult = + | ({ + status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' + purchaseMessageId: string + householdId: string + } & PurchaseProposalFields) + | { + status: 'forbidden' + householdId: string + } + | { + status: 'not_pending' + householdId: string + } + | { + status: 'not_found' } export interface PurchaseMessageIngestionRepository { save( record: PurchaseTopicRecord, - llmFallback?: PurchaseParserLlmFallback, + interpreter?: PurchaseMessageInterpreter, defaultCurrency?: 'GEL' | 'USD' ): Promise + confirm( + purchaseMessageId: string, + actorTelegramUserId: string + ): Promise + cancel( + purchaseMessageId: string, + actorTelegramUserId: string + ): Promise +} + +interface PurchasePersistenceDecision { + status: 'pending_confirmation' | 'clarification_needed' | 'ignored_not_purchase' | 'parse_failed' + parsedAmountMinor: bigint | null + parsedCurrency: 'GEL' | 'USD' | null + parsedItemDescription: string | null + parserConfidence: number | null + parserMode: 'llm' | null + clarificationQuestion: string | null + parserError: string | null + needsReview: boolean +} + +function normalizeInterpretation( + interpretation: PurchaseInterpretation | null, + parserError: string | null +): PurchasePersistenceDecision { + if (parserError !== null || interpretation === null) { + return { + status: 'parse_failed', + parsedAmountMinor: null, + parsedCurrency: null, + parsedItemDescription: null, + parserConfidence: null, + parserMode: null, + clarificationQuestion: null, + parserError: parserError ?? 'Purchase interpreter returned no result', + needsReview: true + } + } + + if (interpretation.decision === 'not_purchase') { + return { + status: 'ignored_not_purchase', + parsedAmountMinor: interpretation.amountMinor, + parsedCurrency: interpretation.currency, + parsedItemDescription: interpretation.itemDescription, + parserConfidence: interpretation.confidence, + parserMode: interpretation.parserMode, + clarificationQuestion: null, + parserError: null, + needsReview: false + } + } + + const missingRequiredFields = + interpretation.amountMinor === null || + interpretation.currency === null || + interpretation.itemDescription === null + + if ( + interpretation.decision === 'clarification' || + missingRequiredFields || + interpretation.confidence < MIN_PROPOSAL_CONFIDENCE + ) { + return { + status: 'clarification_needed', + parsedAmountMinor: interpretation.amountMinor, + parsedCurrency: interpretation.currency, + parsedItemDescription: interpretation.itemDescription, + parserConfidence: interpretation.confidence, + parserMode: interpretation.parserMode, + clarificationQuestion: interpretation.clarificationQuestion, + parserError: null, + needsReview: true + } + } + + return { + status: 'pending_confirmation', + parsedAmountMinor: interpretation.amountMinor, + parsedCurrency: interpretation.currency, + parsedItemDescription: interpretation.itemDescription, + parserConfidence: interpretation.confidence, + parserMode: interpretation.parserMode, + clarificationQuestion: null, + parserError: null, + needsReview: false + } +} + +function needsReviewAsInt(value: boolean): number { + return value ? 1 : 0 +} + +function toStoredPurchaseRow(row: { + id: string + householdId: string + senderTelegramUserId: string + parsedAmountMinor: bigint | null + parsedCurrency: string | null + parsedItemDescription: string | null + parserConfidence: number | null + parserMode: string | null + processingStatus: string +}): StoredPurchaseMessageRow { + return { + id: row.id, + householdId: row.householdId, + senderTelegramUserId: row.senderTelegramUserId, + parsedAmountMinor: row.parsedAmountMinor, + parsedCurrency: + row.parsedCurrency === 'USD' || row.parsedCurrency === 'GEL' ? row.parsedCurrency : null, + parsedItemDescription: row.parsedItemDescription, + parserConfidence: row.parserConfidence, + parserMode: row.parserMode === 'llm' ? 'llm' : null, + processingStatus: + row.processingStatus === 'pending_confirmation' || + row.processingStatus === 'clarification_needed' || + row.processingStatus === 'ignored_not_purchase' || + row.processingStatus === 'parse_failed' || + row.processingStatus === 'confirmed' || + row.processingStatus === 'cancelled' || + row.processingStatus === 'parsed' || + row.processingStatus === 'needs_review' + ? row.processingStatus + : 'parse_failed' + } +} + +function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields { + return { + parsedAmountMinor: row.parsedAmountMinor, + parsedCurrency: row.parsedCurrency, + parsedItemDescription: row.parsedItemDescription, + parserConfidence: row.parserConfidence, + parserMode: row.parserMode + } +} + +async function replyToPurchaseMessage( + ctx: Context, + text: string, + replyMarkup?: { + inline_keyboard: Array< + Array<{ + text: string + callback_data: string + }> + > + } +): Promise { + const message = ctx.msg + if (!message) { + return + } + + await ctx.reply(text, { + reply_parameters: { + message_id: message.message_id + }, + ...(replyMarkup + ? { + reply_markup: replyMarkup + } + : {}) + }) +} + +function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { + const message = ctx.message + if (!message || !('text' in message)) { + return null + } + + if (!message.is_topic_message || message.message_thread_id === undefined) { + return null + } + + const senderTelegramUserId = ctx.from?.id?.toString() + if (!senderTelegramUserId) { + return null + } + + const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name] + .filter((part) => !!part && part.trim().length > 0) + .join(' ') + + const candidate: PurchaseTopicCandidate = { + updateId: ctx.update.update_id, + chatId: message.chat.id.toString(), + messageId: message.message_id.toString(), + threadId: message.message_thread_id.toString(), + senderTelegramUserId, + rawText: message.text, + messageSentAt: instantFromEpochSeconds(message.date) + } + + if (senderDisplayName.length > 0) { + candidate.senderDisplayName = senderDisplayName + } + + return candidate } export function extractPurchaseTopicCandidate( @@ -108,10 +380,6 @@ export function resolveConfiguredPurchaseTopicRecord( } } -function needsReviewAsInt(value: boolean): number { - return value ? 1 : 0 -} - export function createPurchaseMessageRepository(databaseUrl: string): { repository: PurchaseMessageIngestionRepository close: () => Promise @@ -121,8 +389,129 @@ export function createPurchaseMessageRepository(databaseUrl: string): { prepare: false }) + async function getStoredMessage( + purchaseMessageId: string + ): Promise { + const rows = await db + .select({ + id: schema.purchaseMessages.id, + householdId: schema.purchaseMessages.householdId, + senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId, + parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor, + parsedCurrency: schema.purchaseMessages.parsedCurrency, + parsedItemDescription: schema.purchaseMessages.parsedItemDescription, + parserConfidence: schema.purchaseMessages.parserConfidence, + parserMode: schema.purchaseMessages.parserMode, + processingStatus: schema.purchaseMessages.processingStatus + }) + .from(schema.purchaseMessages) + .where(eq(schema.purchaseMessages.id, purchaseMessageId)) + .limit(1) + + const row = rows[0] + return row ? toStoredPurchaseRow(row) : null + } + + async function mutateProposalStatus( + purchaseMessageId: string, + actorTelegramUserId: string, + targetStatus: 'confirmed' | 'cancelled' + ): Promise { + const existing = await getStoredMessage(purchaseMessageId) + if (!existing) { + return { + status: 'not_found' + } + } + + if (existing.senderTelegramUserId !== actorTelegramUserId) { + return { + status: 'forbidden', + householdId: existing.householdId + } + } + + if (existing.processingStatus === targetStatus) { + return { + status: targetStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled', + purchaseMessageId: existing.id, + householdId: existing.householdId, + ...toProposalFields(existing) + } + } + + if (existing.processingStatus !== 'pending_confirmation') { + return { + status: 'not_pending', + householdId: existing.householdId + } + } + + const rows = await db + .update(schema.purchaseMessages) + .set({ + processingStatus: targetStatus, + ...(targetStatus === 'confirmed' + ? { + needsReview: 0 + } + : {}) + }) + .where( + and( + eq(schema.purchaseMessages.id, purchaseMessageId), + eq(schema.purchaseMessages.senderTelegramUserId, actorTelegramUserId), + eq(schema.purchaseMessages.processingStatus, 'pending_confirmation') + ) + ) + .returning({ + id: schema.purchaseMessages.id, + householdId: schema.purchaseMessages.householdId, + senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId, + parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor, + parsedCurrency: schema.purchaseMessages.parsedCurrency, + parsedItemDescription: schema.purchaseMessages.parsedItemDescription, + parserConfidence: schema.purchaseMessages.parserConfidence, + parserMode: schema.purchaseMessages.parserMode, + processingStatus: schema.purchaseMessages.processingStatus + }) + + const updated = rows[0] + if (!updated) { + const reloaded = await getStoredMessage(purchaseMessageId) + if (!reloaded) { + return { + status: 'not_found' + } + } + + if (reloaded.processingStatus === 'confirmed' || reloaded.processingStatus === 'cancelled') { + return { + status: + reloaded.processingStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled', + purchaseMessageId: reloaded.id, + householdId: reloaded.householdId, + ...toProposalFields(reloaded) + } + } + + return { + status: 'not_pending', + householdId: reloaded.householdId + } + } + + const stored = toStoredPurchaseRow(updated) + return { + status: targetStatus, + purchaseMessageId: stored.id, + householdId: stored.householdId, + ...toProposalFields(stored) + } + } + const repository: PurchaseMessageIngestionRepository = { - async save(record, llmFallback, defaultCurrency) { + async save(record, interpreter, defaultCurrency) { const matchedMember = await db .select({ id: schema.members.id }) .from(schema.members) @@ -137,35 +526,19 @@ export function createPurchaseMessageRepository(databaseUrl: string): { const senderMemberId = matchedMember[0]?.id ?? null let parserError: string | null = null - const parsed = await parsePurchaseMessage( - { - rawText: record.rawText - }, - { - ...(llmFallback - ? { - llmFallback - } - : {}), - ...(defaultCurrency - ? { - defaultCurrency - } - : {}) - } - ).catch((error) => { - parserError = error instanceof Error ? error.message : 'Unknown parser error' - return null - }) + const interpretation = interpreter + ? await interpreter(record.rawText, { + defaultCurrency: defaultCurrency ?? 'GEL' + }).catch((error) => { + parserError = error instanceof Error ? error.message : 'Unknown interpreter error' + return null + }) + : null - const processingStatus = - parserError !== null - ? 'parse_failed' - : parsed === null - ? 'needs_review' - : parsed.needsReview - ? 'needs_review' - : 'parsed' + const decision = normalizeInterpretation( + interpretation, + parserError ?? (interpreter ? null : 'Purchase interpreter is unavailable') + ) const inserted = await db .insert(schema.purchaseMessages) @@ -180,14 +553,14 @@ export function createPurchaseMessageRepository(databaseUrl: string): { telegramThreadId: record.threadId, telegramUpdateId: String(record.updateId), messageSentAt: instantToDate(record.messageSentAt), - parsedAmountMinor: parsed?.amountMinor, - parsedCurrency: parsed?.currency, - parsedItemDescription: parsed?.itemDescription, - parserMode: parsed?.parserMode, - parserConfidence: parsed?.confidence, - needsReview: needsReviewAsInt(parsed?.needsReview ?? true), - parserError, - processingStatus + parsedAmountMinor: decision.parsedAmountMinor, + parsedCurrency: decision.parsedCurrency, + parsedItemDescription: decision.parsedItemDescription, + parserMode: decision.parserMode, + parserConfidence: decision.parserConfidence, + needsReview: needsReviewAsInt(decision.needsReview), + parserError: decision.parserError, + processingStatus: decision.status }) .onConflictDoNothing({ target: [ @@ -198,21 +571,54 @@ export function createPurchaseMessageRepository(databaseUrl: string): { }) .returning({ id: schema.purchaseMessages.id }) - if (inserted.length === 0) { + const insertedRow = inserted[0] + if (!insertedRow) { return { status: 'duplicate' } } - return { - status: 'created', - processingStatus, - parsedAmountMinor: parsed?.amountMinor ?? null, - parsedCurrency: parsed?.currency ?? null, - parsedItemDescription: parsed?.itemDescription ?? null, - parserConfidence: parsed?.confidence ?? null, - parserMode: parsed?.parserMode ?? null + switch (decision.status) { + case 'ignored_not_purchase': + return { + status: 'ignored_not_purchase', + purchaseMessageId: insertedRow.id + } + case 'clarification_needed': + return { + status: 'clarification_needed', + purchaseMessageId: insertedRow.id, + clarificationQuestion: decision.clarificationQuestion, + parsedAmountMinor: decision.parsedAmountMinor, + parsedCurrency: decision.parsedCurrency, + parsedItemDescription: decision.parsedItemDescription, + parserConfidence: decision.parserConfidence, + parserMode: decision.parserMode + } + case 'pending_confirmation': + return { + status: 'pending_confirmation', + purchaseMessageId: insertedRow.id, + parsedAmountMinor: decision.parsedAmountMinor!, + parsedCurrency: decision.parsedCurrency!, + parsedItemDescription: decision.parsedItemDescription!, + parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE, + parserMode: decision.parserMode ?? 'llm' + } + case 'parse_failed': + return { + status: 'parse_failed', + purchaseMessageId: insertedRow.id + } } + }, + + async confirm(purchaseMessageId, actorTelegramUserId) { + return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'confirmed') + }, + + async cancel(purchaseMessageId, actorTelegramUserId) { + return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'cancelled') } } @@ -226,7 +632,11 @@ export function createPurchaseMessageRepository(databaseUrl: string): { function formatPurchaseSummary( locale: BotLocale, - result: Extract + result: { + parsedAmountMinor: bigint | null + parsedCurrency: 'GEL' | 'USD' | null + parsedItemDescription: string | null + } ): string { if ( result.parsedAmountMinor === null || @@ -240,73 +650,250 @@ function formatPurchaseSummary( return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}` } +function clarificationFallback(locale: BotLocale, result: PurchaseClarificationResult): string { + const t = getBotTranslations(locale).purchase + + if (result.parsedAmountMinor === null && result.parsedCurrency === null) { + return t.clarificationMissingAmountAndCurrency + } + + if (result.parsedAmountMinor === null) { + return t.clarificationMissingAmount + } + + if (result.parsedCurrency === null) { + return t.clarificationMissingCurrency + } + + if (result.parsedItemDescription === null) { + return t.clarificationMissingItem + } + + return t.clarificationLowConfidence +} + export function buildPurchaseAcknowledgement( result: PurchaseMessageIngestionResult, locale: BotLocale = 'en' ): string | null { - if (result.status === 'duplicate') { - return null - } - const t = getBotTranslations(locale).purchase - switch (result.processingStatus) { - case 'parsed': - return t.recorded(formatPurchaseSummary(locale, result)) - case 'needs_review': - return t.savedForReview(formatPurchaseSummary(locale, result)) + switch (result.status) { + case 'duplicate': + case 'ignored_not_purchase': + return null + case 'pending_confirmation': + return t.proposal(formatPurchaseSummary(locale, result)) + case 'clarification_needed': + return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result)) case 'parse_failed': return t.parseFailed } } -async function replyToPurchaseMessage(ctx: Context, text: string): Promise { - const message = ctx.msg - if (!message) { +function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) { + const t = getBotTranslations(locale).purchase + + return { + inline_keyboard: [ + [ + { + text: t.confirmButton, + callback_data: `${PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}` + }, + { + text: t.cancelButton, + callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}` + } + ] + ] + } +} + +async function resolveHouseholdLocale( + householdConfigurationRepository: HouseholdConfigurationRepository | undefined, + householdId: string +): Promise { + if (!householdConfigurationRepository) { + return 'en' + } + + const householdChat = + await householdConfigurationRepository.getHouseholdChatByHouseholdId(householdId) + return householdChat?.defaultLocale ?? 'en' +} + +async function handlePurchaseMessageResult( + ctx: Context, + record: PurchaseTopicRecord, + result: PurchaseMessageIngestionResult, + locale: BotLocale, + logger: Logger | undefined +): Promise { + if (result.status !== 'duplicate') { + logger?.info( + { + event: 'purchase.ingested', + householdId: record.householdId, + status: result.status, + chatId: record.chatId, + threadId: record.threadId, + messageId: record.messageId, + updateId: record.updateId, + senderTelegramUserId: record.senderTelegramUserId + }, + 'Purchase topic message processed' + ) + } + + const acknowledgement = buildPurchaseAcknowledgement(result, locale) + if (!acknowledgement) { return } - await ctx.reply(text, { - reply_parameters: { - message_id: message.message_id - } - }) + await replyToPurchaseMessage( + ctx, + acknowledgement, + result.status === 'pending_confirmation' + ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId) + : undefined + ) } -function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { - const message = ctx.message - if (!message || !('text' in message)) { - return null +function emptyInlineKeyboard() { + return { + inline_keyboard: [] + } +} + +function buildPurchaseActionMessage( + locale: BotLocale, + result: Extract< + PurchaseProposalActionResult, + { status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' } + > +): string { + const t = getBotTranslations(locale).purchase + const summary = formatPurchaseSummary(locale, result) + + if (result.status === 'confirmed' || result.status === 'already_confirmed') { + return t.confirmed(summary) } - if (!message.is_topic_message || message.message_thread_id === undefined) { - return null - } + return t.cancelled(summary) +} - const senderTelegramUserId = ctx.from?.id?.toString() - if (!senderTelegramUserId) { - return null - } +function registerPurchaseProposalCallbacks( + bot: Bot, + repository: PurchaseMessageIngestionRepository, + resolveLocale: (householdId: string) => Promise, + logger?: Logger +): void { + bot.callbackQuery(new RegExp(`^${PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { + const purchaseMessageId = ctx.match[1] + const actorTelegramUserId = ctx.from?.id?.toString() - const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name] - .filter((part) => !!part && part.trim().length > 0) - .join(' ') + if (!actorTelegramUserId || !purchaseMessageId) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } - const candidate: PurchaseTopicCandidate = { - updateId: ctx.update.update_id, - chatId: message.chat.id.toString(), - messageId: message.message_id.toString(), - threadId: message.message_thread_id.toString(), - senderTelegramUserId, - rawText: message.text, - messageSentAt: instantFromEpochSeconds(message.date) - } + const result = await repository.confirm(purchaseMessageId, actorTelegramUserId) + const locale = 'householdId' in result ? await resolveLocale(result.householdId) : 'en' + const t = getBotTranslations(locale).purchase - if (senderDisplayName.length > 0) { - candidate.senderDisplayName = senderDisplayName - } + if (result.status === 'not_found' || result.status === 'not_pending') { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } - return candidate + if (result.status === 'forbidden') { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: result.status === 'confirmed' ? t.confirmedToast : t.alreadyConfirmed + }) + + if (ctx.msg) { + await ctx.editMessageText(buildPurchaseActionMessage(locale, result), { + reply_markup: emptyInlineKeyboard() + }) + } + + logger?.info( + { + event: 'purchase.confirmation', + purchaseMessageId, + actorTelegramUserId, + status: result.status + }, + 'Purchase proposal confirmation handled' + ) + }) + + bot.callbackQuery(new RegExp(`^${PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { + const purchaseMessageId = ctx.match[1] + const actorTelegramUserId = ctx.from?.id?.toString() + + if (!actorTelegramUserId || !purchaseMessageId) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const result = await repository.cancel(purchaseMessageId, actorTelegramUserId) + const locale = 'householdId' in result ? await resolveLocale(result.householdId) : 'en' + const t = getBotTranslations(locale).purchase + + if (result.status === 'not_found' || result.status === 'not_pending') { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + if (result.status === 'forbidden') { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: result.status === 'cancelled' ? t.cancelledToast : t.alreadyCancelled + }) + + if (ctx.msg) { + await ctx.editMessageText(buildPurchaseActionMessage(locale, result), { + reply_markup: emptyInlineKeyboard() + }) + } + + logger?.info( + { + event: 'purchase.cancellation', + purchaseMessageId, + actorTelegramUserId, + status: result.status + }, + 'Purchase proposal cancellation handled' + ) + }) } export function registerPurchaseTopicIngestion( @@ -314,10 +901,12 @@ export function registerPurchaseTopicIngestion( config: PurchaseTopicIngestionConfig, repository: PurchaseMessageIngestionRepository, options: { - llmFallback?: PurchaseParserLlmFallback + interpreter?: PurchaseMessageInterpreter logger?: Logger } = {} ): void { + void registerPurchaseProposalCallbacks(bot, repository, async () => 'en', options.logger) + bot.on('message:text', async (ctx, next) => { const candidate = toCandidateFromContext(ctx) if (!candidate) { @@ -332,27 +921,8 @@ export function registerPurchaseTopicIngestion( } try { - const status = await repository.save(record, options.llmFallback, 'GEL') - const acknowledgement = buildPurchaseAcknowledgement(status, 'en') - - if (status.status === 'created') { - options.logger?.info( - { - event: 'purchase.ingested', - processingStatus: status.processingStatus, - chatId: record.chatId, - threadId: record.threadId, - messageId: record.messageId, - updateId: record.updateId, - senderTelegramUserId: record.senderTelegramUserId - }, - 'Purchase topic message ingested' - ) - } - - if (acknowledgement) { - await replyToPurchaseMessage(ctx, acknowledgement) - } + const result = await repository.save(record, options.interpreter, 'GEL') + await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger) } catch (error) { options.logger?.error( { @@ -374,10 +944,17 @@ export function registerConfiguredPurchaseTopicIngestion( householdConfigurationRepository: HouseholdConfigurationRepository, repository: PurchaseMessageIngestionRepository, options: { - llmFallback?: PurchaseParserLlmFallback + interpreter?: PurchaseMessageInterpreter logger?: Logger } = {} ): void { + void registerPurchaseProposalCallbacks( + bot, + repository, + async (householdId) => resolveHouseholdLocale(householdConfigurationRepository, householdId), + options.logger + ) + bot.on('message:text', async (ctx, next) => { const candidate = toCandidateFromContext(ctx) if (!candidate) { @@ -405,38 +982,17 @@ export function registerConfiguredPurchaseTopicIngestion( const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings( record.householdId ) - const status = await repository.save( - record, - options.llmFallback, - billingSettings.settlementCurrency - ) - const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId( + const locale = await resolveHouseholdLocale( + householdConfigurationRepository, record.householdId ) - const acknowledgement = buildPurchaseAcknowledgement( - status, - householdChat?.defaultLocale ?? 'en' + const result = await repository.save( + record, + options.interpreter, + billingSettings.settlementCurrency ) - if (status.status === 'created') { - options.logger?.info( - { - event: 'purchase.ingested', - householdId: record.householdId, - processingStatus: status.processingStatus, - chatId: record.chatId, - threadId: record.threadId, - messageId: record.messageId, - updateId: record.updateId, - senderTelegramUserId: record.senderTelegramUserId - }, - 'Purchase topic message ingested' - ) - } - - if (acknowledgement) { - await replyToPurchaseMessage(ctx, acknowledgement) - } + await handlePurchaseMessageResult(ctx, record, result, locale, options.logger) } catch (error) { options.logger?.error( { diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index fa287d0..a087ed7 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -304,7 +304,7 @@ export function createDbFinanceRepository( parsedCurrency: input.currency, parsedItemDescription: input.description, needsReview: 0, - processingStatus: 'parsed', + processingStatus: 'confirmed', parserError: null }) .where( @@ -597,6 +597,10 @@ export function createDbFinanceRepository( isNotNull(schema.purchaseMessages.senderMemberId), isNotNull(schema.purchaseMessages.parsedAmountMinor), isNotNull(schema.purchaseMessages.parsedCurrency), + or( + eq(schema.purchaseMessages.processingStatus, 'parsed'), + eq(schema.purchaseMessages.processingStatus, 'confirmed') + ), gte(schema.purchaseMessages.messageSentAt, instantToDate(start)), lt(schema.purchaseMessages.messageSentAt, instantToDate(end)) ) diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index 3645fb0..3c34d34 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -32,6 +32,7 @@ const server = { .transform((value) => parseOptionalCsv(value)), OPENAI_API_KEY: z.string().min(1).optional(), PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'), + PURCHASE_PARSER_MODEL: z.string().min(1).default('gpt-5-mini'), SCHEDULER_SHARED_SECRET: z.string().min(1).optional() }