From 014d791bdc051aafe09dd842ebd48d9a14629f6d Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 12 Mar 2026 15:35:02 +0400 Subject: [PATCH] fix(bot): improve calculated purchase confirmation flow --- apps/bot/src/dm-assistant.ts | 2 + apps/bot/src/i18n/locales/en.ts | 14 +- apps/bot/src/i18n/locales/ru.ts | 14 +- apps/bot/src/i18n/types.ts | 12 +- .../src/openai-purchase-interpreter.test.ts | 119 ++++++- apps/bot/src/openai-purchase-interpreter.ts | 90 ++---- apps/bot/src/purchase-topic-ingestion.test.ts | 290 ++++++++++++++++++ apps/bot/src/purchase-topic-ingestion.ts | 237 +++++++++++++- 8 files changed, 708 insertions(+), 70 deletions(-) diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 544d1d1..8dc5c59 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -1078,6 +1078,7 @@ export function registerDmAssistant(options: { purchaseResult.status === 'pending_confirmation' ? getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, purchaseResult), + null, null ) : purchaseResult.status === 'clarification_needed' @@ -1358,6 +1359,7 @@ export function registerDmAssistant(options: { if (purchaseResult.status === 'pending_confirmation') { const purchaseText = getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, purchaseResult), + null, null ) diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index cbd64dc..86400fb 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -288,8 +288,12 @@ export const enBotTranslations: BotTranslationCatalog = { purchase: { sharedPurchaseFallback: 'shared purchase', processing: 'Checking that purchase...', - proposal: (summary, participants) => - `I think this shared purchase was: ${summary}.${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, + proposal: (summary: string, calculationNote: string | null, participants: string | null) => + `I think this shared purchase was: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`, + calculatedAmountNote: (explanation: string | null) => + explanation + ? `I calculated the total as ${explanation}. Is that right?` + : 'I calculated the total for this purchase. Is that right?', clarification: (question) => question, clarificationMissingAmountAndCurrency: 'What amount and currency should I record for this shared purchase?', @@ -304,7 +308,13 @@ export const enBotTranslations: BotTranslationCatalog = { participantToggleIncluded: (displayName) => `✅ ${displayName}`, participantToggleExcluded: (displayName) => `⬜ ${displayName}`, confirmButton: 'Confirm', + calculatedConfirmButton: 'Looks right', + calculatedFixAmountButton: 'Fix amount', cancelButton: 'Cancel', + calculatedFixAmountPrompt: + 'Reply with the corrected total and currency in this topic, and I will re-check the purchase.', + calculatedFixAmountRequestedToast: 'Reply with the corrected total.', + calculatedFixAmountAlreadyRequested: 'Waiting for the corrected total.', confirmed: (summary) => `Purchase confirmed: ${summary}`, cancelled: (summary) => `Purchase proposal cancelled: ${summary}`, confirmedToast: 'Purchase confirmed.', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 8def867..a97c166 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -292,8 +292,12 @@ export const ruBotTranslations: BotTranslationCatalog = { purchase: { sharedPurchaseFallback: 'общая покупка', processing: 'Проверяю покупку...', - proposal: (summary, participants) => - `Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`, + proposal: (summary: string, calculationNote: string | null, participants: string | null) => + `Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`, + calculatedAmountNote: (explanation: string | null) => + explanation + ? `Я посчитал итог как ${explanation}. Всё верно?` + : 'Я посчитал итоговую сумму для этой покупки. Всё верно?', clarification: (question) => question, clarificationMissingAmountAndCurrency: 'Какую сумму и валюту нужно записать для этой общей покупки?', @@ -308,7 +312,13 @@ export const ruBotTranslations: BotTranslationCatalog = { participantToggleIncluded: (displayName) => `✅ ${displayName}`, participantToggleExcluded: (displayName) => `⬜ ${displayName}`, confirmButton: 'Подтвердить', + calculatedConfirmButton: 'Верно', + calculatedFixAmountButton: 'Исправить сумму', cancelButton: 'Отменить', + calculatedFixAmountPrompt: + 'Ответьте в этот топик исправленной итоговой суммой и валютой, и я заново проверю покупку.', + calculatedFixAmountRequestedToast: 'Ответьте исправленной суммой.', + calculatedFixAmountAlreadyRequested: 'Жду исправленную сумму.', confirmed: (summary) => `Покупка подтверждена: ${summary}`, cancelled: (summary) => `Предложение покупки отменено: ${summary}`, confirmedToast: 'Покупка подтверждена.', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 5f348c9..314830b 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -268,7 +268,12 @@ export interface BotTranslationCatalog { purchase: { sharedPurchaseFallback: string processing: string - proposal: (summary: string, participants: string | null) => string + proposal: ( + summary: string, + calculationNote: string | null, + participants: string | null + ) => string + calculatedAmountNote: (explanation: string | null) => string clarification: (question: string) => string clarificationMissingAmountAndCurrency: string clarificationMissingAmount: string @@ -281,7 +286,12 @@ export interface BotTranslationCatalog { participantToggleIncluded: (displayName: string) => string participantToggleExcluded: (displayName: string) => string confirmButton: string + calculatedConfirmButton: string + calculatedFixAmountButton: string cancelButton: string + calculatedFixAmountPrompt: string + calculatedFixAmountRequestedToast: string + calculatedFixAmountAlreadyRequested: string confirmed: (summary: string) => string cancelled: (summary: string) => string confirmedToast: string diff --git a/apps/bot/src/openai-purchase-interpreter.test.ts b/apps/bot/src/openai-purchase-interpreter.test.ts index 9703c90..9b664cb 100644 --- a/apps/bot/src/openai-purchase-interpreter.test.ts +++ b/apps/bot/src/openai-purchase-interpreter.test.ts @@ -67,6 +67,8 @@ describe('createOpenAiPurchaseInterpreter', () => { amountMinor: 100000n, currency: 'GEL', itemDescription: 'армянская золотая швабра', + amountSource: 'explicit', + calculationExplanation: null, confidence: 93, parserMode: 'llm', clarificationQuestion: null @@ -104,6 +106,8 @@ describe('createOpenAiPurchaseInterpreter', () => { amountMinor: 1000n, currency: 'GEL', itemDescription: 'сухари', + amountSource: 'explicit', + calculationExplanation: null, confidence: 88, parserMode: 'llm', clarificationQuestion: null @@ -148,6 +152,8 @@ describe('createOpenAiPurchaseInterpreter', () => { amountMinor: 5000n, currency: 'GEL', itemDescription: 'шампунь', + amountSource: 'explicit', + calculationExplanation: null, confidence: 92, parserMode: 'llm', clarificationQuestion: null @@ -192,6 +198,8 @@ describe('createOpenAiPurchaseInterpreter', () => { amountMinor: 4500n, currency: 'GEL', itemDescription: 'сосисоны', + amountSource: 'explicit', + calculationExplanation: null, confidence: 85, parserMode: 'llm', clarificationQuestion: null @@ -201,7 +209,7 @@ describe('createOpenAiPurchaseInterpreter', () => { } }) - test('corrects mis-scaled amountMinor when the source text contains a clear money amount', async () => { + test('keeps the llm provided amountMinor without local correction', async () => { const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini') expect(interpreter).toBeDefined() @@ -236,9 +244,11 @@ describe('createOpenAiPurchaseInterpreter', () => { expect(result).toEqual({ decision: 'purchase', - amountMinor: 35000n, + amountMinor: 350n, currency: 'GEL', itemDescription: 'обои, 100 рулонов', + amountSource: 'explicit', + calculationExplanation: null, confidence: 86, parserMode: 'llm', clarificationQuestion: null @@ -248,7 +258,7 @@ describe('createOpenAiPurchaseInterpreter', () => { } }) - test('corrects mis-scaled amountMinor for simple clarification replies', async () => { + test('keeps llm provided amountMinor for clarification follow-ups without local correction', async () => { const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini') expect(interpreter).toBeDefined() @@ -283,9 +293,11 @@ describe('createOpenAiPurchaseInterpreter', () => { expect(result).toEqual({ decision: 'purchase', - amountMinor: 35000n, + amountMinor: 350n, currency: 'GEL', itemDescription: 'Рулоны обоев', + amountSource: 'explicit', + calculationExplanation: null, confidence: 89, parserMode: 'llm', clarificationQuestion: null @@ -294,4 +306,103 @@ describe('createOpenAiPurchaseInterpreter', () => { globalThis.fetch = originalFetch } }) + + test('preserves llm computed totals for quantity times unit-price purchases', async () => { + const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini') + expect(interpreter).toBeDefined() + + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + successfulResponse({ + output: [ + { + content: [ + { + text: JSON.stringify({ + decision: 'purchase', + amountMinor: '3000', + currency: 'GEL', + itemDescription: 'бутылки воды', + amountSource: 'calculated', + calculationExplanation: '5 × 6 лари = 30 лари', + confidence: 94, + clarificationQuestion: null + }) + } + ] + } + ] + })) as unknown as typeof fetch + + try { + const result = await interpreter!('Купил 5 бутылок воды, 6 лари за бутылку', { + defaultCurrency: 'GEL' + }) + + expect(result).toEqual({ + decision: 'purchase', + amountMinor: 3000n, + currency: 'GEL', + itemDescription: 'бутылки воды', + amountSource: 'calculated', + calculationExplanation: '5 × 6 лари = 30 лари', + confidence: 94, + parserMode: 'llm', + clarificationQuestion: null + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test('tells the llm to total per-item pricing and accept colloquial completed purchase phrasing', async () => { + const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini') + expect(interpreter).toBeDefined() + + const originalFetch = globalThis.fetch + let requestBody: unknown = null + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + requestBody = init?.body ? JSON.parse(String(init.body)) : null + + return successfulResponse({ + output: [ + { + content: [ + { + text: JSON.stringify({ + decision: 'purchase', + amountMinor: '3000', + currency: 'GEL', + itemDescription: 'бутылки воды', + confidence: 94, + clarificationQuestion: null + }) + } + ] + } + ] + }) + }) as unknown as typeof fetch + + try { + await interpreter!('Купил 5 бутылок воды, 6 лари за бутылку', { + defaultCurrency: 'GEL' + }) + + const systemMessage = + ( + (requestBody as { input?: Array<{ role?: string; content?: string }> | null })?.input ?? + [] + ).find((entry) => entry.role === 'system')?.content ?? '' + + expect(systemMessage).toContain( + 'If the user gives quantity and per-item price, compute the total spend and return that total in amountMinor.' + ) + expect(systemMessage).toContain( + 'Treat colloquial completed-buy phrasing like "взял", "сходил и взял", or "сторговался до X" as a completed purchase when the message reports a real buy fact.' + ) + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/apps/bot/src/openai-purchase-interpreter.ts b/apps/bot/src/openai-purchase-interpreter.ts index c0a1922..324603c 100644 --- a/apps/bot/src/openai-purchase-interpreter.ts +++ b/apps/bot/src/openai-purchase-interpreter.ts @@ -1,12 +1,15 @@ import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses' export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase' +export type PurchaseInterpretationAmountSource = 'explicit' | 'calculated' export interface PurchaseInterpretation { decision: PurchaseInterpretationDecision amountMinor: bigint | null currency: 'GEL' | 'USD' | null itemDescription: string | null + amountSource?: PurchaseInterpretationAmountSource | null + calculationExplanation?: string | null confidence: number parserMode: 'llm' clarificationQuestion: string | null @@ -31,6 +34,8 @@ interface OpenAiStructuredResult { amountMinor: string | null currency: 'GEL' | 'USD' | null itemDescription: string | null + amountSource: PurchaseInterpretationAmountSource | null + calculationExplanation: string | null confidence: number clarificationQuestion: string | null } @@ -53,61 +58,15 @@ function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null { return value === 'GEL' || value === 'USD' ? value : null } -function toMinorUnits(rawAmount: string): bigint { - const normalized = rawAmount.replace(',', '.') - const [wholePart, fractionalPart = ''] = normalized.split('.') - const cents = fractionalPart.padEnd(2, '0').slice(0, 2) - - return BigInt(`${wholePart}${cents}`) -} - -function extractLikelyMoneyAmountMinor(rawText: string): bigint | null { - const moneyCueMatches = Array.from( - rawText.matchAll( - /(?:за|выложил(?:а)?|отдал(?:а)?|заплатил(?:а)?|потратил(?:а)?|стоит|стоило)\s*(\d+(?:[.,]\d{1,2})?)/giu - ) - ) - if (moneyCueMatches.length === 1) { - const rawAmount = moneyCueMatches[0]?.[1] - if (rawAmount) { - return toMinorUnits(rawAmount) - } - } - - const explicitMoneyMatches = Array.from( - rawText.matchAll( - /(\d+(?:[.,]\d{1,2})?)\s*(?:₾|gel|lari|лари|usd|\$|доллар(?:а|ов)?|кровн\p{L}*)/giu - ) - ) - if (explicitMoneyMatches.length === 1) { - const rawAmount = explicitMoneyMatches[0]?.[1] - if (rawAmount) { - return toMinorUnits(rawAmount) - } - } - - const standaloneMatches = Array.from(rawText.matchAll(/\b(\d+(?:[.,]\d{1,2})?)\b/gu)) - if (standaloneMatches.length === 1) { - const rawAmount = standaloneMatches[0]?.[1] - if (rawAmount) { - return toMinorUnits(rawAmount) - } - } - - return null -} - -function resolveAmountMinor(input: { rawText: string; amountMinor: bigint | null }): bigint | null { - if (input.amountMinor === null) { +function normalizeAmountSource( + value: PurchaseInterpretationAmountSource | null, + amountMinor: bigint | null +): PurchaseInterpretationAmountSource | null { + if (amountMinor === null) { return null } - const explicitAmountMinor = extractLikelyMoneyAmountMinor(input.rawText) - if (explicitAmountMinor === null) { - return input.amountMinor - } - - return explicitAmountMinor === input.amountMinor * 100n ? explicitAmountMinor : input.amountMinor + return value === 'calculated' ? 'calculated' : 'explicit' } function normalizeConfidence(value: number): number { @@ -183,7 +142,11 @@ export function createOpenAiPurchaseInterpreter( 'Decide whether the latest message is a real shared purchase, needs clarification, or is not a shared purchase at all.', `The household default currency is ${options.defaultCurrency}. If a real purchase clearly omits currency, use ${options.defaultCurrency}.`, 'amountMinor must be expressed in minor currency units. Example: 350 GEL -> 35000, 3.50 GEL -> 350, 45 lari -> 4500.', + 'If the user gives quantity and per-item price, compute the total spend and return that total in amountMinor.', + 'Set amountSource to "explicit" when the user directly states the total amount, or "calculated" when you compute it from quantity x price or similar arithmetic.', + 'When amountSource is "calculated", also return a short calculationExplanation in the user message language, such as "5 × 6 lari = 30 lari".', 'Ignore item quantities like rolls, kilograms, or layers unless they are clearly the money amount.', + 'Treat colloquial completed-buy phrasing like "взял", "сходил и взял", or "сторговался до X" as a completed purchase when the message reports a real buy fact.', 'If recent messages from the same sender are provided, treat them as clarification context for the latest message.', 'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.', 'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.', @@ -233,6 +196,18 @@ export function createOpenAiPurchaseInterpreter( itemDescription: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + amountSource: { + anyOf: [ + { + type: 'string', + enum: ['explicit', 'calculated'] + }, + { type: 'null' } + ] + }, + calculationExplanation: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, confidence: { type: 'number', minimum: 0, @@ -247,6 +222,8 @@ export function createOpenAiPurchaseInterpreter( 'amountMinor', 'currency', 'itemDescription', + 'amountSource', + 'calculationExplanation', 'confidence', 'clarificationQuestion' ] @@ -286,11 +263,10 @@ export function createOpenAiPurchaseInterpreter( return null } - const amountMinor = resolveAmountMinor({ - rawText, - amountMinor: asOptionalBigInt(parsedJson.amountMinor) - }) + const amountMinor = asOptionalBigInt(parsedJson.amountMinor) const itemDescription = normalizeOptionalText(parsedJson.itemDescription) + const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor) + const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation) const currency = resolveMissingCurrency({ decision: parsedJson.decision, amountMinor, @@ -315,6 +291,8 @@ export function createOpenAiPurchaseInterpreter( amountMinor, currency, itemDescription, + amountSource, + calculationExplanation: amountSource === 'calculated' ? calculationExplanation : null, confidence: normalizeConfidence(parsedJson.confidence), parserMode: 'llm', clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 16c5bf5..af55010 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -220,6 +220,8 @@ describe('buildPurchaseAcknowledgement', () => { parsedAmountMinor: 3000n, parsedCurrency: 'GEL', parsedItemDescription: 'toilet paper', + amountSource: 'explicit', + calculationExplanation: null, parserConfidence: 92, parserMode: 'llm', participants: participants() @@ -227,6 +229,29 @@ describe('buildPurchaseAcknowledgement', () => { expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL. +Participants: +- Mia +- Dima (excluded) +Confirm or cancel below.`) + }) + + test('shows a calculation note when the llm computed the total', () => { + const result = buildPurchaseAcknowledgement({ + status: 'pending_confirmation', + purchaseMessageId: 'proposal-1b', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'water bottles', + amountSource: 'calculated', + calculationExplanation: '5 x 6 lari = 30 lari', + parserConfidence: 94, + parserMode: 'llm', + participants: participants() + }) + + expect(result).toBe(`I think this shared purchase was: water bottles - 30.00 GEL. +I calculated the total as 5 x 6 lari = 30 lari. Is that right? + Participants: - Mia - Dima (excluded) @@ -241,6 +266,8 @@ Confirm or cancel below.`) parsedAmountMinor: 3000n, parsedCurrency: null, parsedItemDescription: 'toilet paper', + amountSource: 'explicit', + calculationExplanation: null, parserConfidence: 61, parserMode: 'llm' }) @@ -256,6 +283,8 @@ Confirm or cancel below.`) parsedAmountMinor: null, parsedCurrency: null, parsedItemDescription: 'toilet paper', + amountSource: null, + calculationExplanation: null, parserConfidence: 42, parserMode: 'llm' }) @@ -297,6 +326,8 @@ Confirm or cancel below.`) parsedAmountMinor: 3000n, parsedCurrency: 'GEL', parsedItemDescription: 'туалетная бумага', + amountSource: 'explicit', + calculationExplanation: null, parserConfidence: 92, parserMode: 'llm', participants: participants() @@ -734,6 +765,212 @@ Confirm or cancel below.`, expect(calls).toHaveLength(0) }) + test('treats colloquial completed purchase reports as likely purchases', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save(record) { + expect(record.rawText).toBe( + 'Короч, сходил на рынок и взял этот долбаный ковер. Сторговался до 150 лари' + ) + return { + status: 'pending_confirmation', + purchaseMessageId: 'proposal-carpet', + parsedAmountMinor: 15000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'ковер', + parserConfidence: 91, + parserMode: 'llm', + participants: participants() + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'purchase', + amountMinor: 15000n, + currency: 'GEL', + itemDescription: 'ковер', + confidence: 91, + parserMode: 'llm', + clarificationQuestion: null + }) + }) + + await bot.handleUpdate( + purchaseUpdate( + 'Короч, сходил на рынок и взял этот долбаный ковер. Сторговался до 150 лари' + ) as never + ) + + expect(calls).toHaveLength(3) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'Checking that purchase...' + } + }) + expect(calls[2]).toMatchObject({ + method: 'editMessageText', + payload: { + text: `I think this shared purchase was: ковер - 150.00 GEL. + +Participants: +- Mia +- Dima (excluded) +Confirm or cancel below.` + } + }) + }) + + test('uses dedicated buttons for calculated totals', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save() { + return { + status: 'pending_confirmation', + purchaseMessageId: 'proposal-calculated', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'water bottles', + amountSource: 'calculated', + calculationExplanation: '5 x 6 lari = 30 lari', + parserConfidence: 94, + parserMode: 'llm', + participants: participants() + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'purchase', + amountMinor: 3000n, + currency: 'GEL', + itemDescription: 'water bottles', + amountSource: 'calculated', + calculationExplanation: '5 x 6 lari = 30 lari', + confidence: 94, + parserMode: 'llm', + clarificationQuestion: null + }) + }) + + await bot.handleUpdate(purchaseUpdate('Bought 5 bottles of water, 6 lari each') as never) + + expect(calls[2]).toMatchObject({ + method: 'editMessageText', + payload: { + reply_markup: { + inline_keyboard: [ + [ + { + text: '✅ Mia', + callback_data: 'purchase:participant:participant-1' + } + ], + [ + { + text: '⬜ Dima', + callback_data: 'purchase:participant:participant-2' + } + ], + [ + { + text: 'Looks right', + callback_data: 'purchase:confirm:proposal-calculated' + }, + { + text: 'Fix amount', + callback_data: 'purchase:fix_amount:proposal-calculated' + }, + { + text: 'Cancel', + callback_data: 'purchase:cancel:proposal-calculated' + } + ] + ] + } + } + }) + }) + test('stays silent for stray amount chatter in the purchase topic', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] @@ -1366,6 +1603,59 @@ Confirm or cancel below.`, }) }) + test('requests amount correction for calculated purchase proposals', 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 hasClarificationContext() { + return false + }, + async save() { + throw new Error('not used') + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + }, + async requestAmountCorrection() { + return { + status: 'requested', + purchaseMessageId: 'proposal-1', + householdId: config.householdId + } + } + } + + registerPurchaseTopicIngestion(bot, config, repository) + await bot.handleUpdate(callbackUpdate('purchase:fix_amount:proposal-1') as never) + + expect(calls).toHaveLength(2) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + text: 'Reply with the corrected total and currency in this topic, and I will re-check the purchase.', + reply_markup: { + inline_keyboard: [] + } + } + }) + }) + test('handles duplicate confirm callbacks idempotently', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 5278337..f413cd8 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -10,6 +10,7 @@ import type { import { createDbClient, schema } from '@household/db' import { getBotTranslations, type BotLocale } from './i18n' import type { + PurchaseInterpretationAmountSource, PurchaseInterpretation, PurchaseMessageInterpreter } from './openai-purchase-interpreter' @@ -19,13 +20,14 @@ import { stripExplicitBotMention } from './telegram-mentions' const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:' const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:' const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:' +const PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX = 'purchase:fix_amount:' const MIN_PROPOSAL_CONFIDENCE = 70 const LIKELY_PURCHASE_VERB_PATTERN = - /\b(?:bought|purchased|paid|spent|ordered|picked up|grabbed|got)\b|\b(?:купил(?:а|и)?|куплено|заказал(?:а|и)?|оплатил(?:а|и)?|потратил(?:а|и)?|взял(?:а|и)?)\b/iu + /\b(?:bought|purchased|paid|spent|ordered|picked up|grabbed|got)\b|(?:^|[^\p{L}])(?:купил(?:а|и)?|куплено|заказал(?:а|и)?|оплатил(?:а|и)?|потратил(?:а|и)?|взял(?:а|и)?)(?=$|[^\p{L}])/iu const PLANNING_PURCHASE_PATTERN = - /\b(?:should buy|should get|need to buy|need to get|want to buy|want to get|let'?s buy|let'?s get|going to buy|gonna buy|plan to buy|planning to buy|thinking about buying|thinking of buying|should we buy|should we get|can buy)\b|\b(?:надо|нужно|хочу|хотим|давай(?:те)?|будем|планирую|планируем|может|стоит)\s+(?:купить|взять|заказать|оплатить)\b|\b(?:купим|возьмем|возьмём|закажем|оплатим)\b/iu + /\b(?:should buy|should get|need to buy|need to get|want to buy|want to get|let'?s buy|let'?s get|going to buy|gonna buy|plan to buy|planning to buy|thinking about buying|thinking of buying|should we buy|should we get|can buy)\b|(?:^|[^\p{L}])(?:надо|нужно|хочу|хотим|давай(?:те)?|будем|планирую|планируем|может|стоит)\s+(?:купить|взять|заказать|оплатить)(?=$|[^\p{L}])|(?:^|[^\p{L}])(?:купим|возьмем|возьмём|закажем|оплатим)(?=$|[^\p{L}])/iu const MONEY_SIGNAL_PATTERN = - /\b\d+(?:[.,]\d{1,2})?\s*(?:₾|gel|lari|лари|tetri|тетри|usd|\$|доллар(?:а|ов)?)\b|\b(?:for|за|на)\s+\d+(?:[.,]\d{1,2})?\b|\b(?:paid|spent|заплатил(?:а|и)?|потратил(?:а|и)?|отдал(?:а|и)?|выложил(?:а|и)?)\s+\d+(?:[.,]\d{1,2})?\b/iu + /\b\d+(?:[.,]\d{1,2})?\s*(?:₾|gel|lari|usd|\$)\b|\d+(?:[.,]\d{1,2})?\s*(?:лари|лри|tetri|тетри|доллар(?:а|ов)?)(?=$|[^\p{L}])|\b(?:for|за|на|до)\s+\d+(?:[.,]\d{1,2})?\b|\b(?:paid|spent)\s+\d+(?:[.,]\d{1,2})?\b|(?:^|[^\p{L}])(?:заплатил(?:а|и)?|потратил(?:а|и)?|отдал(?:а|и)?|выложил(?:а|и)?|сторговался(?:\s+до)?)(?:\s+\d+(?:[.,]\d{1,2})?|\s+до\s+\d+(?:[.,]\d{1,2})?)(?=$|[^\p{L}])/iu const STANDALONE_NUMBER_PATTERN = /\b\d+(?:[.,]\d{1,2})?\b/gu type PurchaseTopicEngagement = @@ -68,6 +70,8 @@ interface PurchaseProposalFields { parsedAmountMinor: bigint | null parsedCurrency: 'GEL' | 'USD' | null parsedItemDescription: string | null + amountSource?: PurchaseInterpretationAmountSource | null + calculationExplanation?: string | null parserConfidence: number | null parserMode: 'llm' | null } @@ -173,6 +177,29 @@ export type PurchaseProposalParticipantToggleResult = status: 'not_found' } +export type PurchaseProposalAmountCorrectionResult = + | { + status: 'requested' + purchaseMessageId: string + householdId: string + } + | { + status: 'already_requested' + purchaseMessageId: string + householdId: string + } + | { + status: 'forbidden' + householdId: string + } + | { + status: 'not_pending' + householdId: string + } + | { + status: 'not_found' + } + export interface PurchaseMessageIngestionRepository { hasClarificationContext(record: PurchaseTopicRecord): Promise save( @@ -196,6 +223,10 @@ export interface PurchaseMessageIngestionRepository { participantId: string, actorTelegramUserId: string ): Promise + requestAmountCorrection?( + purchaseMessageId: string, + actorTelegramUserId: string + ): Promise } interface PurchasePersistenceDecision { @@ -203,6 +234,8 @@ interface PurchasePersistenceDecision { parsedAmountMinor: bigint | null parsedCurrency: 'GEL' | 'USD' | null parsedItemDescription: string | null + amountSource: PurchaseInterpretationAmountSource | null + calculationExplanation: string | null parserConfidence: number | null parserMode: 'llm' | null clarificationQuestion: string | null @@ -292,6 +325,8 @@ function normalizeInterpretation( parsedAmountMinor: null, parsedCurrency: null, parsedItemDescription: null, + amountSource: null, + calculationExplanation: null, parserConfidence: null, parserMode: null, clarificationQuestion: null, @@ -306,6 +341,8 @@ function normalizeInterpretation( parsedAmountMinor: interpretation.amountMinor, parsedCurrency: interpretation.currency, parsedItemDescription: interpretation.itemDescription, + amountSource: interpretation.amountSource ?? null, + calculationExplanation: interpretation.calculationExplanation ?? null, parserConfidence: interpretation.confidence, parserMode: interpretation.parserMode, clarificationQuestion: null, @@ -329,6 +366,8 @@ function normalizeInterpretation( parsedAmountMinor: interpretation.amountMinor, parsedCurrency: interpretation.currency, parsedItemDescription: interpretation.itemDescription, + amountSource: interpretation.amountSource ?? null, + calculationExplanation: interpretation.calculationExplanation ?? null, parserConfidence: interpretation.confidence, parserMode: interpretation.parserMode, clarificationQuestion: interpretation.clarificationQuestion, @@ -342,6 +381,8 @@ function normalizeInterpretation( parsedAmountMinor: interpretation.amountMinor, parsedCurrency: interpretation.currency, parsedItemDescription: interpretation.itemDescription, + amountSource: interpretation.amountSource ?? null, + calculationExplanation: interpretation.calculationExplanation ?? null, parserConfidence: interpretation.confidence, parserMode: interpretation.parserMode, clarificationQuestion: null, @@ -398,6 +439,8 @@ function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields parsedAmountMinor: row.parsedAmountMinor, parsedCurrency: row.parsedCurrency, parsedItemDescription: row.parsedItemDescription, + amountSource: null, + calculationExplanation: null, parserConfidence: row.parserConfidence, parserMode: row.parserMode } @@ -991,6 +1034,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedAmountMinor: decision.parsedAmountMinor, parsedCurrency: decision.parsedCurrency, parsedItemDescription: decision.parsedItemDescription, + amountSource: decision.amountSource, + calculationExplanation: decision.calculationExplanation, parserConfidence: decision.parserConfidence, parserMode: decision.parserMode } @@ -1018,6 +1063,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { parsedAmountMinor: decision.parsedAmountMinor!, parsedCurrency: decision.parsedCurrency!, parsedItemDescription: decision.parsedItemDescription!, + amountSource: decision.amountSource, + calculationExplanation: decision.calculationExplanation, parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE, parserMode: decision.parserMode ?? 'llm', participants: toProposalParticipants(await getStoredParticipants(insertedRow.id)) @@ -1135,6 +1182,84 @@ export function createPurchaseMessageRepository(databaseUrl: string): { await getStoredParticipants(existing.purchaseMessageId) ) } + }, + + async requestAmountCorrection(purchaseMessageId, actorTelegramUserId) { + const existing = await getStoredMessage(purchaseMessageId) + if (!existing) { + return { + status: 'not_found' + } + } + + if (existing.senderTelegramUserId !== actorTelegramUserId) { + return { + status: 'forbidden', + householdId: existing.householdId + } + } + + if (existing.processingStatus === 'clarification_needed') { + return { + status: 'already_requested', + purchaseMessageId: existing.id, + householdId: existing.householdId + } + } + + if (existing.processingStatus !== 'pending_confirmation') { + return { + status: 'not_pending', + householdId: existing.householdId + } + } + + const rows = await db + .update(schema.purchaseMessages) + .set({ + processingStatus: 'clarification_needed', + needsReview: 1 + }) + .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 + }) + + const updated = rows[0] + if (!updated) { + const reloaded = await getStoredMessage(purchaseMessageId) + if (!reloaded) { + return { + status: 'not_found' + } + } + + if (reloaded.processingStatus === 'clarification_needed') { + return { + status: 'already_requested', + purchaseMessageId: reloaded.id, + householdId: reloaded.householdId + } + } + + return { + status: 'not_pending', + householdId: reloaded.householdId + } + } + + return { + status: 'requested', + purchaseMessageId: updated.id, + householdId: updated.householdId + } } } @@ -1206,6 +1331,21 @@ function formatPurchaseParticipants( return `${t.participantsHeading}\n${lines.join('\n')}` } +function formatPurchaseCalculationNote( + locale: BotLocale, + result: { + amountSource?: PurchaseInterpretationAmountSource | null + calculationExplanation?: string | null + } +): string | null { + if (result.amountSource !== 'calculated') { + return null + } + + const t = getBotTranslations(locale).purchase + return t.calculatedAmountNote(result.calculationExplanation ?? null) +} + export function buildPurchaseAcknowledgement( result: PurchaseMessageIngestionResult, locale: BotLocale = 'en' @@ -1219,6 +1359,7 @@ export function buildPurchaseAcknowledgement( case 'pending_confirmation': return t.proposal( formatPurchaseSummary(locale, result), + formatPurchaseCalculationNote(locale, result), formatPurchaseParticipants(locale, result.participants) ) case 'clarification_needed': @@ -1230,6 +1371,9 @@ export function buildPurchaseAcknowledgement( function purchaseProposalReplyMarkup( locale: BotLocale, + options: { + amountSource?: PurchaseInterpretationAmountSource | null + }, purchaseMessageId: string, participants: readonly PurchaseProposalParticipant[] ) { @@ -1247,9 +1391,17 @@ function purchaseProposalReplyMarkup( ]), [ { - text: t.confirmButton, + text: options.amountSource === 'calculated' ? t.calculatedConfirmButton : t.confirmButton, callback_data: `${PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}` }, + ...(options.amountSource === 'calculated' + ? [ + { + text: t.calculatedFixAmountButton, + callback_data: `${PURCHASE_FIX_AMOUNT_CALLBACK_PREFIX}${purchaseMessageId}` + } + ] + : []), { text: t.cancelButton, callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}` @@ -1319,7 +1471,14 @@ async function handlePurchaseMessageResult( pendingReply, acknowledgement, result.status === 'pending_confirmation' - ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId, result.participants) + ? purchaseProposalReplyMarkup( + locale, + { + amountSource: result.amountSource ?? null + }, + result.purchaseMessageId, + result.participants + ) : undefined ) } @@ -1353,6 +1512,7 @@ function buildPurchaseToggleMessage( ): string { return getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, result), + null, formatPurchaseParticipants(locale, result.participants) ) } @@ -1409,6 +1569,9 @@ function registerPurchaseProposalCallbacks( await ctx.editMessageText(buildPurchaseToggleMessage(locale, result), { reply_markup: purchaseProposalReplyMarkup( locale, + { + amountSource: result.amountSource ?? null + }, result.purchaseMessageId, result.participants ) @@ -1479,6 +1642,70 @@ function registerPurchaseProposalCallbacks( ) }) + bot.callbackQuery(new RegExp(`^${PURCHASE_FIX_AMOUNT_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 + } + + if (!repository.requestAmountCorrection) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const result = await repository.requestAmountCorrection(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 === 'requested' + ? t.calculatedFixAmountRequestedToast + : t.calculatedFixAmountAlreadyRequested + }) + + if (ctx.msg) { + await ctx.editMessageText(t.calculatedFixAmountPrompt, { + reply_markup: emptyInlineKeyboard() + }) + } + + logger?.info( + { + event: 'purchase.amount_correction_requested', + purchaseMessageId, + actorTelegramUserId, + status: result.status + }, + 'Purchase amount correction requested' + ) + }) + bot.callbackQuery(new RegExp(`^${PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { const purchaseMessageId = ctx.match[1] const actorTelegramUserId = ctx.from?.id?.toString()