diff --git a/apps/bot/src/openai-purchase-interpreter.test.ts b/apps/bot/src/openai-purchase-interpreter.test.ts index 9c4a7e9..9703c90 100644 --- a/apps/bot/src/openai-purchase-interpreter.test.ts +++ b/apps/bot/src/openai-purchase-interpreter.test.ts @@ -200,4 +200,98 @@ describe('createOpenAiPurchaseInterpreter', () => { globalThis.fetch = originalFetch } }) + + test('corrects mis-scaled amountMinor when the source text contains a clear money amount', async () => { + const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini') + expect(interpreter).toBeDefined() + + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + successfulResponse({ + output: [ + { + content: [ + { + text: JSON.stringify({ + decision: 'purchase', + amountMinor: '350', + currency: 'GEL', + itemDescription: 'обои, 100 рулонов', + confidence: 86, + clarificationQuestion: null + }) + } + ] + } + ] + })) as unknown as typeof fetch + + try { + const result = await interpreter!( + 'Купил обои, 100 рулонов, чтобы клеить в 3 слоя. Выложил 350 кровных', + { + defaultCurrency: 'GEL' + } + ) + + expect(result).toEqual({ + decision: 'purchase', + amountMinor: 35000n, + currency: 'GEL', + itemDescription: 'обои, 100 рулонов', + confidence: 86, + parserMode: 'llm', + clarificationQuestion: null + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test('corrects mis-scaled amountMinor for simple clarification replies', async () => { + const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-4o-mini') + expect(interpreter).toBeDefined() + + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + successfulResponse({ + output: [ + { + content: [ + { + text: JSON.stringify({ + decision: 'purchase', + amountMinor: '350', + currency: 'GEL', + itemDescription: 'Рулоны обоев', + confidence: 89, + clarificationQuestion: null + }) + } + ] + } + ] + })) as unknown as typeof fetch + + try { + const result = await interpreter!('350', { + defaultCurrency: 'GEL', + clarificationContext: { + recentMessages: ['Купил обои, 100 рулонов, чтобы клеить в 3 слоя. Выложил 350 кровных'] + } + }) + + expect(result).toEqual({ + decision: 'purchase', + amountMinor: 35000n, + currency: 'GEL', + itemDescription: 'Рулоны обоев', + confidence: 89, + parserMode: 'llm', + clarificationQuestion: null + }) + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/apps/bot/src/openai-purchase-interpreter.ts b/apps/bot/src/openai-purchase-interpreter.ts index 11c82d3..186a66b 100644 --- a/apps/bot/src/openai-purchase-interpreter.ts +++ b/apps/bot/src/openai-purchase-interpreter.ts @@ -51,6 +51,63 @@ 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) { + return null + } + + const explicitAmountMinor = extractLikelyMoneyAmountMinor(input.rawText) + if (explicitAmountMinor === null) { + return input.amountMinor + } + + return explicitAmountMinor === input.amountMinor * 100n ? explicitAmountMinor : input.amountMinor +} + function normalizeConfidence(value: number): number { const scaled = value >= 0 && value <= 1 ? value * 100 : value return Math.max(0, Math.min(100, Math.round(scaled))) @@ -123,6 +180,8 @@ export function createOpenAiPurchaseInterpreter( 'You classify a purchase candidate from a household shared-purchases topic.', '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.', + 'Ignore item quantities like rolls, kilograms, or layers unless they are clearly the money amount.', '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.', @@ -216,7 +275,10 @@ export function createOpenAiPurchaseInterpreter( return null } - const amountMinor = asOptionalBigInt(parsedJson.amountMinor) + const amountMinor = resolveAmountMinor({ + rawText, + amountMinor: asOptionalBigInt(parsedJson.amountMinor) + }) const itemDescription = normalizeOptionalText(parsedJson.itemDescription) const currency = resolveMissingCurrency({ decision: parsedJson.decision,