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

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