mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
fix(bot): correct mis-scaled purchase amounts
This commit is contained in:
@@ -200,4 +200,98 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
|||||||
globalThis.fetch = originalFetch
|
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<PurchaseInterpretation>({
|
||||||
|
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<PurchaseInterpretation>({
|
||||||
|
decision: 'purchase',
|
||||||
|
amountMinor: 35000n,
|
||||||
|
currency: 'GEL',
|
||||||
|
itemDescription: 'Рулоны обоев',
|
||||||
|
confidence: 89,
|
||||||
|
parserMode: 'llm',
|
||||||
|
clarificationQuestion: null
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,6 +51,63 @@ function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null {
|
|||||||
return value === 'GEL' || value === 'USD' ? value : 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 {
|
function normalizeConfidence(value: number): number {
|
||||||
const scaled = value >= 0 && value <= 1 ? value * 100 : value
|
const scaled = value >= 0 && value <= 1 ? value * 100 : value
|
||||||
return Math.max(0, Math.min(100, Math.round(scaled)))
|
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.',
|
'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.',
|
'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}.`,
|
`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 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 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.',
|
'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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
|
const amountMinor = resolveAmountMinor({
|
||||||
|
rawText,
|
||||||
|
amountMinor: asOptionalBigInt(parsedJson.amountMinor)
|
||||||
|
})
|
||||||
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
||||||
const currency = resolveMissingCurrency({
|
const currency = resolveMissingCurrency({
|
||||||
decision: parsedJson.decision,
|
decision: parsedJson.decision,
|
||||||
|
|||||||
Reference in New Issue
Block a user