mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 03:24:02 +00:00
fix(bot): improve calculated purchase confirmation flow
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user