fix(bot): improve calculated purchase confirmation flow

This commit is contained in:
2026-03-12 15:35:02 +04:00
parent 995725f121
commit 014d791bdc
8 changed files with 708 additions and 70 deletions

View File

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

View File

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

View File

@@ -292,8 +292,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
purchase: {
sharedPurchaseFallback: 'общая покупка',
processing: 'Проверяю покупку...',
proposal: (summary, participants) =>
`Похоже, это общая покупка: ${summary}.${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`,
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
`Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\одтвердите или отмените ниже.`,
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: 'Покупка подтверждена.',

View File

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

View File

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

View File

@@ -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)
}
}
function normalizeAmountSource(
value: PurchaseInterpretationAmountSource | null,
amountMinor: bigint | null
): PurchaseInterpretationAmountSource | null {
if (amountMinor === null) {
return null
}
function resolveAmountMinor(input: { rawText: string; amountMinor: bigint | null }): bigint | null {
if (input.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

View File

@@ -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 }> = []

View File

@@ -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<boolean>
save(
@@ -196,6 +223,10 @@ export interface PurchaseMessageIngestionRepository {
participantId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalParticipantToggleResult>
requestAmountCorrection?(
purchaseMessageId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalAmountCorrectionResult>
}
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()