mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(bot): add confirmed purchase proposals in topic ingestion
This commit is contained in:
@@ -15,6 +15,7 @@ export interface BotRuntimeConfig {
|
|||||||
reminderJobsEnabled: boolean
|
reminderJobsEnabled: boolean
|
||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
parserModel: string
|
parserModel: string
|
||||||
|
purchaseParserModel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePort(raw: string | undefined): number {
|
function parsePort(raw: string | undefined): number {
|
||||||
@@ -103,7 +104,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
miniAppAuthEnabled,
|
miniAppAuthEnabled,
|
||||||
schedulerOidcAllowedEmails,
|
schedulerOidcAllowedEmails,
|
||||||
reminderJobsEnabled,
|
reminderJobsEnabled,
|
||||||
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini',
|
||||||
|
purchaseParserModel:
|
||||||
|
env.PURCHASE_PARSER_MODEL?.trim() || env.PARSER_MODEL?.trim() || 'gpt-5-mini'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (databaseUrl !== undefined) {
|
if (databaseUrl !== undefined) {
|
||||||
|
|||||||
@@ -164,9 +164,27 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
},
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'shared purchase',
|
sharedPurchaseFallback: 'shared purchase',
|
||||||
recorded: (summary) => `Recorded purchase: ${summary}`,
|
proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`,
|
||||||
savedForReview: (summary) => `Saved for review: ${summary}`,
|
clarification: (question) => question,
|
||||||
parseFailed: "Saved for review: I couldn't parse this purchase yet."
|
clarificationMissingAmountAndCurrency:
|
||||||
|
'What amount and currency should I record for this shared purchase?',
|
||||||
|
clarificationMissingAmount: 'What amount should I record for this shared purchase?',
|
||||||
|
clarificationMissingCurrency: 'Which currency was this purchase in?',
|
||||||
|
clarificationMissingItem: 'What exactly was purchased?',
|
||||||
|
clarificationLowConfidence:
|
||||||
|
'I am not confident I understood this. Please restate the shared purchase with item, amount, and currency.',
|
||||||
|
confirmButton: 'Confirm',
|
||||||
|
cancelButton: 'Cancel',
|
||||||
|
confirmed: (summary) => `Purchase confirmed: ${summary}`,
|
||||||
|
cancelled: (summary) => `Purchase proposal cancelled: ${summary}`,
|
||||||
|
confirmedToast: 'Purchase confirmed.',
|
||||||
|
cancelledToast: 'Purchase cancelled.',
|
||||||
|
alreadyConfirmed: 'This purchase was already confirmed.',
|
||||||
|
alreadyCancelled: 'This purchase was already cancelled.',
|
||||||
|
notYourProposal: 'Only the original sender can confirm or cancel this purchase.',
|
||||||
|
proposalUnavailable: 'This purchase proposal is no longer available.',
|
||||||
|
parseFailed:
|
||||||
|
"I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency."
|
||||||
},
|
},
|
||||||
payments: {
|
payments: {
|
||||||
topicMissing:
|
topicMissing:
|
||||||
|
|||||||
@@ -167,9 +167,27 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
},
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'общая покупка',
|
sharedPurchaseFallback: 'общая покупка',
|
||||||
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
|
||||||
savedForReview: (summary) => `Сохранено на проверку: ${summary}`,
|
clarification: (question) => question,
|
||||||
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.'
|
clarificationMissingAmountAndCurrency:
|
||||||
|
'Какую сумму и валюту нужно записать для этой общей покупки?',
|
||||||
|
clarificationMissingAmount: 'Какую сумму нужно записать для этой общей покупки?',
|
||||||
|
clarificationMissingCurrency: 'В какой валюте была эта покупка?',
|
||||||
|
clarificationMissingItem: 'Что именно было куплено?',
|
||||||
|
clarificationLowConfidence:
|
||||||
|
'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.',
|
||||||
|
confirmButton: 'Подтвердить',
|
||||||
|
cancelButton: 'Отменить',
|
||||||
|
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
|
||||||
|
cancelled: (summary) => `Предложение покупки отменено: ${summary}`,
|
||||||
|
confirmedToast: 'Покупка подтверждена.',
|
||||||
|
cancelledToast: 'Покупка отменена.',
|
||||||
|
alreadyConfirmed: 'Эта покупка уже подтверждена.',
|
||||||
|
alreadyCancelled: 'Это предложение покупки уже отменено.',
|
||||||
|
notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.',
|
||||||
|
proposalUnavailable: 'Это предложение покупки уже недоступно.',
|
||||||
|
parseFailed:
|
||||||
|
'Пока не удалось распознать это как общую покупку. Напишите предмет, сумму и валюту явно.'
|
||||||
},
|
},
|
||||||
payments: {
|
payments: {
|
||||||
topicMissing:
|
topicMissing:
|
||||||
|
|||||||
@@ -187,8 +187,23 @@ export interface BotTranslationCatalog {
|
|||||||
}
|
}
|
||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: string
|
sharedPurchaseFallback: string
|
||||||
recorded: (summary: string) => string
|
proposal: (summary: string) => string
|
||||||
savedForReview: (summary: string) => string
|
clarification: (question: string) => string
|
||||||
|
clarificationMissingAmountAndCurrency: string
|
||||||
|
clarificationMissingAmount: string
|
||||||
|
clarificationMissingCurrency: string
|
||||||
|
clarificationMissingItem: string
|
||||||
|
clarificationLowConfidence: string
|
||||||
|
confirmButton: string
|
||||||
|
cancelButton: string
|
||||||
|
confirmed: (summary: string) => string
|
||||||
|
cancelled: (summary: string) => string
|
||||||
|
confirmedToast: string
|
||||||
|
cancelledToast: string
|
||||||
|
alreadyConfirmed: string
|
||||||
|
alreadyCancelled: string
|
||||||
|
notYourProposal: string
|
||||||
|
proposalUnavailable: string
|
||||||
parseFailed: string
|
parseFailed: string
|
||||||
}
|
}
|
||||||
payments: {
|
payments: {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { createFinanceCommandsService } from './finance-commands'
|
|||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { getBotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig } from './config'
|
||||||
import { registerHouseholdSetupCommands } from './household-setup'
|
import { registerHouseholdSetupCommands } from './household-setup'
|
||||||
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
registerConfiguredPurchaseTopicIngestion
|
registerConfiguredPurchaseTopicIngestion
|
||||||
@@ -184,16 +184,19 @@ if (telegramPendingActionRepositoryClient) {
|
|||||||
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
||||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||||
shutdownTasks.push(purchaseRepositoryClient.close)
|
shutdownTasks.push(purchaseRepositoryClient.close)
|
||||||
const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel)
|
const purchaseInterpreter = createOpenAiPurchaseInterpreter(
|
||||||
|
runtime.openaiApiKey,
|
||||||
|
runtime.purchaseParserModel
|
||||||
|
)
|
||||||
|
|
||||||
registerConfiguredPurchaseTopicIngestion(
|
registerConfiguredPurchaseTopicIngestion(
|
||||||
bot,
|
bot,
|
||||||
householdConfigurationRepositoryClient.repository,
|
householdConfigurationRepositoryClient.repository,
|
||||||
purchaseRepositoryClient.repository,
|
purchaseRepositoryClient.repository,
|
||||||
{
|
{
|
||||||
...(llmFallback
|
...(purchaseInterpreter
|
||||||
? {
|
? {
|
||||||
llmFallback
|
interpreter: purchaseInterpreter
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
logger: getLogger('purchase-ingestion')
|
logger: getLogger('purchase-ingestion')
|
||||||
|
|||||||
173
apps/bot/src/openai-purchase-interpreter.ts
Normal file
173
apps/bot/src/openai-purchase-interpreter.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
|
||||||
|
|
||||||
|
export interface PurchaseInterpretation {
|
||||||
|
decision: PurchaseInterpretationDecision
|
||||||
|
amountMinor: bigint | null
|
||||||
|
currency: 'GEL' | 'USD' | null
|
||||||
|
itemDescription: string | null
|
||||||
|
confidence: number
|
||||||
|
parserMode: 'llm'
|
||||||
|
clarificationQuestion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PurchaseMessageInterpreter = (
|
||||||
|
rawText: string,
|
||||||
|
options: {
|
||||||
|
defaultCurrency: 'GEL' | 'USD'
|
||||||
|
}
|
||||||
|
) => Promise<PurchaseInterpretation | null>
|
||||||
|
|
||||||
|
interface OpenAiStructuredResult {
|
||||||
|
decision: PurchaseInterpretationDecision
|
||||||
|
amountMinor: string | null
|
||||||
|
currency: 'GEL' | 'USD' | null
|
||||||
|
itemDescription: string | null
|
||||||
|
confidence: number
|
||||||
|
clarificationQuestion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function asOptionalBigInt(value: string | null): bigint | null {
|
||||||
|
if (value === null || !/^[0-9]+$/.test(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = BigInt(value)
|
||||||
|
return parsed > 0n ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalText(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
return trimmed && trimmed.length > 0 ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null {
|
||||||
|
return value === 'GEL' || value === 'USD' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpenAiPurchaseInterpreter(
|
||||||
|
apiKey: string | undefined,
|
||||||
|
model: string
|
||||||
|
): PurchaseMessageInterpreter | undefined {
|
||||||
|
if (!apiKey) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (rawText, options) => {
|
||||||
|
const response = await fetch('https://api.openai.com/v1/responses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${apiKey}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: [
|
||||||
|
'You classify a single Telegram message from a household shared-purchases topic.',
|
||||||
|
'Decide whether the message is a real shared purchase, needs clarification, or is not a shared purchase at all.',
|
||||||
|
`The household default currency is ${options.defaultCurrency}, but do not assume that omitted currency means ${options.defaultCurrency}.`,
|
||||||
|
'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.',
|
||||||
|
'Return a clarification question in the same language as the user message when clarification is needed.',
|
||||||
|
'Return only JSON that matches the schema.'
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: rawText
|
||||||
|
}
|
||||||
|
],
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
name: 'purchase_interpretation',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
decision: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['purchase', 'clarification', 'not_purchase']
|
||||||
|
},
|
||||||
|
amountMinor: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
enum: ['GEL', 'USD']
|
||||||
|
},
|
||||||
|
{ type: 'null' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
itemDescription: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
confidence: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 100
|
||||||
|
},
|
||||||
|
clarificationQuestion: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
'decision',
|
||||||
|
'amountMinor',
|
||||||
|
'currency',
|
||||||
|
'itemDescription',
|
||||||
|
'confidence',
|
||||||
|
'clarificationQuestion'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
output_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.output_text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedJson: OpenAiStructuredResult
|
||||||
|
try {
|
||||||
|
parsedJson = JSON.parse(payload.output_text) as OpenAiStructuredResult
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsedJson.decision !== 'purchase' &&
|
||||||
|
parsedJson.decision !== 'clarification' &&
|
||||||
|
parsedJson.decision !== 'not_purchase'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const clarificationQuestion = normalizeOptionalText(parsedJson.clarificationQuestion)
|
||||||
|
if (parsedJson.decision === 'clarification' && !clarificationQuestion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decision: parsedJson.decision,
|
||||||
|
amountMinor: asOptionalBigInt(parsedJson.amountMinor),
|
||||||
|
currency: normalizeCurrency(parsedJson.currency),
|
||||||
|
itemDescription: normalizeOptionalText(parsedJson.itemDescription),
|
||||||
|
confidence: Math.max(0, Math.min(100, Math.round(parsedJson.confidence))),
|
||||||
|
parserMode: 'llm',
|
||||||
|
clarificationQuestion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,51 @@ function purchaseUpdate(text: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function callbackUpdate(data: string, fromId = 10002) {
|
||||||
|
return {
|
||||||
|
update_id: 1002,
|
||||||
|
callback_query: {
|
||||||
|
id: 'callback-1',
|
||||||
|
from: {
|
||||||
|
id: fromId,
|
||||||
|
is_bot: false,
|
||||||
|
first_name: 'Mia'
|
||||||
|
},
|
||||||
|
chat_instance: 'instance-1',
|
||||||
|
data,
|
||||||
|
message: {
|
||||||
|
message_id: 77,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: Number(config.householdChatId),
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: 'placeholder'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestBot() {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
describe('extractPurchaseTopicCandidate', () => {
|
describe('extractPurchaseTopicCandidate', () => {
|
||||||
test('returns record when message belongs to configured topic', () => {
|
test('returns record when message belongs to configured topic', () => {
|
||||||
const record = extractPurchaseTopicCandidate(candidate(), config)
|
const record = extractPurchaseTopicCandidate(candidate(), config)
|
||||||
@@ -127,93 +172,103 @@ describe('resolveConfiguredPurchaseTopicRecord', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('buildPurchaseAcknowledgement', () => {
|
describe('buildPurchaseAcknowledgement', () => {
|
||||||
test('returns parsed acknowledgement with amount summary', () => {
|
test('returns proposal acknowledgement for a likely purchase', () => {
|
||||||
const result = buildPurchaseAcknowledgement({
|
const result = buildPurchaseAcknowledgement({
|
||||||
status: 'created',
|
status: 'pending_confirmation',
|
||||||
processingStatus: 'parsed',
|
purchaseMessageId: 'proposal-1',
|
||||||
parsedAmountMinor: 3000n,
|
parsedAmountMinor: 3000n,
|
||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'rules'
|
parserMode: 'llm'
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result).toBe('Recorded purchase: toilet paper - 30.00 GEL')
|
expect(result).toBe(
|
||||||
|
'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns review acknowledgement when parsing needs review', () => {
|
test('returns explicit clarification text from the interpreter', () => {
|
||||||
const result = buildPurchaseAcknowledgement({
|
const result = buildPurchaseAcknowledgement({
|
||||||
status: 'created',
|
status: 'clarification_needed',
|
||||||
processingStatus: 'needs_review',
|
purchaseMessageId: 'proposal-2',
|
||||||
|
clarificationQuestion: 'Which currency was this purchase in?',
|
||||||
parsedAmountMinor: 3000n,
|
parsedAmountMinor: 3000n,
|
||||||
parsedCurrency: 'GEL',
|
parsedCurrency: null,
|
||||||
parsedItemDescription: 'shared purchase',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 78,
|
parserConfidence: 61,
|
||||||
parserMode: 'rules'
|
parserMode: 'llm'
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result).toBe('Saved for review: shared purchase - 30.00 GEL')
|
expect(result).toBe('Which currency was this purchase in?')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns parse failure acknowledgement without guessed values', () => {
|
test('returns fallback clarification when the interpreter question is missing', () => {
|
||||||
const result = buildPurchaseAcknowledgement({
|
const result = buildPurchaseAcknowledgement({
|
||||||
status: 'created',
|
status: 'clarification_needed',
|
||||||
processingStatus: 'parse_failed',
|
purchaseMessageId: 'proposal-3',
|
||||||
|
clarificationQuestion: null,
|
||||||
parsedAmountMinor: null,
|
parsedAmountMinor: null,
|
||||||
parsedCurrency: null,
|
parsedCurrency: null,
|
||||||
parsedItemDescription: null,
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: null,
|
parserConfidence: 42,
|
||||||
parserMode: null
|
parserMode: 'llm'
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result).toBe("Saved for review: I couldn't parse this purchase yet.")
|
expect(result).toBe('What amount and currency should I record for this shared purchase?')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not acknowledge duplicates', () => {
|
test('returns parse failure acknowledgement without guessing values', () => {
|
||||||
|
const result = buildPurchaseAcknowledgement({
|
||||||
|
status: 'parse_failed',
|
||||||
|
purchaseMessageId: 'proposal-4'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not acknowledge duplicates or non-purchase chatter', () => {
|
||||||
expect(
|
expect(
|
||||||
buildPurchaseAcknowledgement({
|
buildPurchaseAcknowledgement({
|
||||||
status: 'duplicate'
|
status: 'duplicate'
|
||||||
})
|
})
|
||||||
).toBeNull()
|
).toBeNull()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildPurchaseAcknowledgement({
|
||||||
|
status: 'ignored_not_purchase',
|
||||||
|
purchaseMessageId: 'proposal-5'
|
||||||
|
})
|
||||||
|
).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns Russian acknowledgement when requested', () => {
|
test('returns Russian proposal text when requested', () => {
|
||||||
const result = buildPurchaseAcknowledgement(
|
const result = buildPurchaseAcknowledgement(
|
||||||
{
|
{
|
||||||
status: 'created',
|
status: 'pending_confirmation',
|
||||||
processingStatus: 'parsed',
|
purchaseMessageId: 'proposal-6',
|
||||||
parsedAmountMinor: 3000n,
|
parsedAmountMinor: 3000n,
|
||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'туалетная бумага',
|
parsedItemDescription: 'туалетная бумага',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'rules'
|
parserMode: 'llm'
|
||||||
},
|
},
|
||||||
'ru'
|
'ru'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result).toBe('Покупка сохранена: туалетная бумага - 30.00 GEL')
|
expect(result).toBe(
|
||||||
|
'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('registerPurchaseTopicIngestion', () => {
|
describe('registerPurchaseTopicIngestion', () => {
|
||||||
test('replies in-topic after a parsed purchase is recorded', async () => {
|
test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
bot.botInfo = {
|
|
||||||
id: 999000,
|
|
||||||
is_bot: true,
|
|
||||||
first_name: 'Household Test Bot',
|
|
||||||
username: 'household_test_bot',
|
|
||||||
can_join_groups: true,
|
|
||||||
can_read_all_group_messages: false,
|
|
||||||
supports_inline_queries: false,
|
|
||||||
can_connect_to_business: false,
|
|
||||||
has_main_web_app: false,
|
|
||||||
has_topics_enabled: true,
|
|
||||||
allows_users_to_create_topics: false
|
|
||||||
}
|
|
||||||
|
|
||||||
bot.api.config.use(async (_prev, method, payload) => {
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
calls.push({ method, payload })
|
calls.push({ method, payload })
|
||||||
|
|
||||||
@@ -234,14 +289,20 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
const repository: PurchaseMessageIngestionRepository = {
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
async save() {
|
async save() {
|
||||||
return {
|
return {
|
||||||
status: 'created',
|
status: 'pending_confirmation',
|
||||||
processingStatus: 'parsed',
|
purchaseMessageId: 'proposal-1',
|
||||||
parsedAmountMinor: 3000n,
|
parsedAmountMinor: 3000n,
|
||||||
parsedCurrency: 'GEL',
|
parsedCurrency: 'GEL',
|
||||||
parsedItemDescription: 'toilet paper',
|
parsedItemDescription: 'toilet paper',
|
||||||
parserConfidence: 92,
|
parserConfidence: 92,
|
||||||
parserMode: 'rules'
|
parserMode: 'llm'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,27 +316,27 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
reply_parameters: {
|
reply_parameters: {
|
||||||
message_id: 55
|
message_id: 55
|
||||||
},
|
},
|
||||||
text: 'Recorded purchase: toilet paper - 30.00 GEL'
|
text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.',
|
||||||
})
|
reply_markup: {
|
||||||
})
|
inline_keyboard: [
|
||||||
|
[
|
||||||
test('does not reply for duplicate deliveries', async () => {
|
{
|
||||||
const bot = createTelegramBot('000000:test-token')
|
text: 'Confirm',
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
callback_data: 'purchase:confirm:proposal-1'
|
||||||
|
},
|
||||||
bot.botInfo = {
|
{
|
||||||
id: 999000,
|
text: 'Cancel',
|
||||||
is_bot: true,
|
callback_data: 'purchase:cancel:proposal-1'
|
||||||
first_name: 'Household Test Bot',
|
|
||||||
username: 'household_test_bot',
|
|
||||||
can_join_groups: true,
|
|
||||||
can_read_all_group_messages: false,
|
|
||||||
supports_inline_queries: false,
|
|
||||||
can_connect_to_business: false,
|
|
||||||
has_main_web_app: false,
|
|
||||||
has_topics_enabled: true,
|
|
||||||
allows_users_to_create_topics: false
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replies with a clarification question for ambiguous purchases', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
bot.api.config.use(async (_prev, method, payload) => {
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
calls.push({ method, payload })
|
calls.push({ method, payload })
|
||||||
@@ -297,14 +358,240 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
const repository: PurchaseMessageIngestionRepository = {
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
async save() {
|
async save() {
|
||||||
return {
|
return {
|
||||||
status: 'duplicate'
|
status: 'clarification_needed',
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
clarificationQuestion: 'Which currency was this purchase in?',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: null,
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 52,
|
||||||
|
parserMode: 'llm'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(purchaseUpdate('Bought toilet paper for 30') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
text: 'Which currency was this purchase in?'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not reply for duplicate deliveries or non-purchase chatter', async () => {
|
||||||
|
const bot = createTestBot()
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
let saveCall = 0
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: true
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async save() {
|
||||||
|
saveCall += 1
|
||||||
|
return saveCall === 1
|
||||||
|
? {
|
||||||
|
status: 'duplicate' as const
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: 'ignored_not_purchase' as const,
|
||||||
|
purchaseMessageId: 'proposal-1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPurchaseTopicIngestion(bot, config, repository)
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
|
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
|
||||||
|
await bot.handleUpdate(purchaseUpdate('This is not a purchase') as never)
|
||||||
|
|
||||||
expect(calls).toHaveLength(0)
|
expect(calls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('confirms a pending proposal and edits the bot message', 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 save() {
|
||||||
|
return {
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'llm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
return {
|
||||||
|
status: 'confirmed' as const,
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
householdId: config.householdId,
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL' as const,
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'llm' as const
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'answerCallbackQuery',
|
||||||
|
payload: {
|
||||||
|
callback_query_id: 'callback-1',
|
||||||
|
text: 'Purchase confirmed.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(calls[1]).toMatchObject({
|
||||||
|
method: 'editMessageText',
|
||||||
|
payload: {
|
||||||
|
chat_id: Number(config.householdChatId),
|
||||||
|
message_id: 77,
|
||||||
|
text: 'Purchase confirmed: toilet paper - 30.00 GEL',
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles duplicate confirm callbacks idempotently', 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 save() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
return {
|
||||||
|
status: 'already_confirmed' as const,
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
householdId: config.householdId,
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL' as const,
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'llm' as const
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
throw new Error('not used')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never)
|
||||||
|
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'answerCallbackQuery',
|
||||||
|
payload: {
|
||||||
|
callback_query_id: 'callback-1',
|
||||||
|
text: 'This purchase was already confirmed.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(calls[1]).toMatchObject({
|
||||||
|
method: 'editMessageText',
|
||||||
|
payload: {
|
||||||
|
text: 'Purchase confirmed: toilet paper - 30.00 GEL'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancels a pending proposal and edits the bot message', 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 save() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async confirm() {
|
||||||
|
throw new Error('not used')
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
return {
|
||||||
|
status: 'cancelled' as const,
|
||||||
|
purchaseMessageId: 'proposal-1',
|
||||||
|
householdId: config.householdId,
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL' as const,
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'llm' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(callbackUpdate('purchase:cancel:proposal-1') as never)
|
||||||
|
|
||||||
|
expect(calls[0]).toMatchObject({
|
||||||
|
method: 'answerCallbackQuery',
|
||||||
|
payload: {
|
||||||
|
callback_query_id: 'callback-1',
|
||||||
|
text: 'Purchase cancelled.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(calls[1]).toMatchObject({
|
||||||
|
method: 'editMessageText',
|
||||||
|
payload: {
|
||||||
|
text: 'Purchase proposal cancelled: toilet paper - 30.00 GEL'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
|
|
||||||
import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain'
|
import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
@@ -10,6 +9,60 @@ import type {
|
|||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
import { getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
import type {
|
||||||
|
PurchaseInterpretation,
|
||||||
|
PurchaseMessageInterpreter
|
||||||
|
} from './openai-purchase-interpreter'
|
||||||
|
|
||||||
|
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
|
||||||
|
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
|
||||||
|
const MIN_PROPOSAL_CONFIDENCE = 70
|
||||||
|
|
||||||
|
type StoredPurchaseProcessingStatus =
|
||||||
|
| 'pending_confirmation'
|
||||||
|
| 'clarification_needed'
|
||||||
|
| 'ignored_not_purchase'
|
||||||
|
| 'parse_failed'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'parsed'
|
||||||
|
| 'needs_review'
|
||||||
|
|
||||||
|
interface StoredPurchaseMessageRow {
|
||||||
|
id: string
|
||||||
|
householdId: string
|
||||||
|
senderTelegramUserId: string
|
||||||
|
parsedAmountMinor: bigint | null
|
||||||
|
parsedCurrency: 'GEL' | 'USD' | null
|
||||||
|
parsedItemDescription: string | null
|
||||||
|
parserConfidence: number | null
|
||||||
|
parserMode: 'llm' | null
|
||||||
|
processingStatus: StoredPurchaseProcessingStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseProposalFields {
|
||||||
|
parsedAmountMinor: bigint | null
|
||||||
|
parsedCurrency: 'GEL' | 'USD' | null
|
||||||
|
parsedItemDescription: string | null
|
||||||
|
parserConfidence: number | null
|
||||||
|
parserMode: 'llm' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseClarificationResult extends PurchaseProposalFields {
|
||||||
|
status: 'clarification_needed'
|
||||||
|
purchaseMessageId: string
|
||||||
|
clarificationQuestion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchasePendingConfirmationResult extends PurchaseProposalFields {
|
||||||
|
status: 'pending_confirmation'
|
||||||
|
purchaseMessageId: string
|
||||||
|
parsedAmountMinor: bigint
|
||||||
|
parsedCurrency: 'GEL' | 'USD'
|
||||||
|
parsedItemDescription: string
|
||||||
|
parserConfidence: number
|
||||||
|
parserMode: 'llm'
|
||||||
|
}
|
||||||
|
|
||||||
export interface PurchaseTopicIngestionConfig {
|
export interface PurchaseTopicIngestionConfig {
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -32,28 +85,247 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
|
|||||||
householdId: string
|
householdId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PurchaseMessageProcessingStatus = 'parsed' | 'needs_review' | 'parse_failed'
|
|
||||||
|
|
||||||
export type PurchaseMessageIngestionResult =
|
export type PurchaseMessageIngestionResult =
|
||||||
| {
|
| {
|
||||||
status: 'duplicate'
|
status: 'duplicate'
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: 'created'
|
status: 'ignored_not_purchase'
|
||||||
processingStatus: PurchaseMessageProcessingStatus
|
purchaseMessageId: string
|
||||||
parsedAmountMinor: bigint | null
|
}
|
||||||
parsedCurrency: 'GEL' | 'USD' | null
|
| PurchaseClarificationResult
|
||||||
parsedItemDescription: string | null
|
| PurchasePendingConfirmationResult
|
||||||
parserConfidence: number | null
|
| {
|
||||||
parserMode: 'rules' | 'llm' | null
|
status: 'parse_failed'
|
||||||
|
purchaseMessageId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PurchaseProposalActionResult =
|
||||||
|
| ({
|
||||||
|
status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled'
|
||||||
|
purchaseMessageId: string
|
||||||
|
householdId: string
|
||||||
|
} & PurchaseProposalFields)
|
||||||
|
| {
|
||||||
|
status: 'forbidden'
|
||||||
|
householdId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'not_pending'
|
||||||
|
householdId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'not_found'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseMessageIngestionRepository {
|
export interface PurchaseMessageIngestionRepository {
|
||||||
save(
|
save(
|
||||||
record: PurchaseTopicRecord,
|
record: PurchaseTopicRecord,
|
||||||
llmFallback?: PurchaseParserLlmFallback,
|
interpreter?: PurchaseMessageInterpreter,
|
||||||
defaultCurrency?: 'GEL' | 'USD'
|
defaultCurrency?: 'GEL' | 'USD'
|
||||||
): Promise<PurchaseMessageIngestionResult>
|
): Promise<PurchaseMessageIngestionResult>
|
||||||
|
confirm(
|
||||||
|
purchaseMessageId: string,
|
||||||
|
actorTelegramUserId: string
|
||||||
|
): Promise<PurchaseProposalActionResult>
|
||||||
|
cancel(
|
||||||
|
purchaseMessageId: string,
|
||||||
|
actorTelegramUserId: string
|
||||||
|
): Promise<PurchaseProposalActionResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchasePersistenceDecision {
|
||||||
|
status: 'pending_confirmation' | 'clarification_needed' | 'ignored_not_purchase' | 'parse_failed'
|
||||||
|
parsedAmountMinor: bigint | null
|
||||||
|
parsedCurrency: 'GEL' | 'USD' | null
|
||||||
|
parsedItemDescription: string | null
|
||||||
|
parserConfidence: number | null
|
||||||
|
parserMode: 'llm' | null
|
||||||
|
clarificationQuestion: string | null
|
||||||
|
parserError: string | null
|
||||||
|
needsReview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInterpretation(
|
||||||
|
interpretation: PurchaseInterpretation | null,
|
||||||
|
parserError: string | null
|
||||||
|
): PurchasePersistenceDecision {
|
||||||
|
if (parserError !== null || interpretation === null) {
|
||||||
|
return {
|
||||||
|
status: 'parse_failed',
|
||||||
|
parsedAmountMinor: null,
|
||||||
|
parsedCurrency: null,
|
||||||
|
parsedItemDescription: null,
|
||||||
|
parserConfidence: null,
|
||||||
|
parserMode: null,
|
||||||
|
clarificationQuestion: null,
|
||||||
|
parserError: parserError ?? 'Purchase interpreter returned no result',
|
||||||
|
needsReview: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interpretation.decision === 'not_purchase') {
|
||||||
|
return {
|
||||||
|
status: 'ignored_not_purchase',
|
||||||
|
parsedAmountMinor: interpretation.amountMinor,
|
||||||
|
parsedCurrency: interpretation.currency,
|
||||||
|
parsedItemDescription: interpretation.itemDescription,
|
||||||
|
parserConfidence: interpretation.confidence,
|
||||||
|
parserMode: interpretation.parserMode,
|
||||||
|
clarificationQuestion: null,
|
||||||
|
parserError: null,
|
||||||
|
needsReview: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingRequiredFields =
|
||||||
|
interpretation.amountMinor === null ||
|
||||||
|
interpretation.currency === null ||
|
||||||
|
interpretation.itemDescription === null
|
||||||
|
|
||||||
|
if (
|
||||||
|
interpretation.decision === 'clarification' ||
|
||||||
|
missingRequiredFields ||
|
||||||
|
interpretation.confidence < MIN_PROPOSAL_CONFIDENCE
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 'clarification_needed',
|
||||||
|
parsedAmountMinor: interpretation.amountMinor,
|
||||||
|
parsedCurrency: interpretation.currency,
|
||||||
|
parsedItemDescription: interpretation.itemDescription,
|
||||||
|
parserConfidence: interpretation.confidence,
|
||||||
|
parserMode: interpretation.parserMode,
|
||||||
|
clarificationQuestion: interpretation.clarificationQuestion,
|
||||||
|
parserError: null,
|
||||||
|
needsReview: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
parsedAmountMinor: interpretation.amountMinor,
|
||||||
|
parsedCurrency: interpretation.currency,
|
||||||
|
parsedItemDescription: interpretation.itemDescription,
|
||||||
|
parserConfidence: interpretation.confidence,
|
||||||
|
parserMode: interpretation.parserMode,
|
||||||
|
clarificationQuestion: null,
|
||||||
|
parserError: null,
|
||||||
|
needsReview: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsReviewAsInt(value: boolean): number {
|
||||||
|
return value ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStoredPurchaseRow(row: {
|
||||||
|
id: string
|
||||||
|
householdId: string
|
||||||
|
senderTelegramUserId: string
|
||||||
|
parsedAmountMinor: bigint | null
|
||||||
|
parsedCurrency: string | null
|
||||||
|
parsedItemDescription: string | null
|
||||||
|
parserConfidence: number | null
|
||||||
|
parserMode: string | null
|
||||||
|
processingStatus: string
|
||||||
|
}): StoredPurchaseMessageRow {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
householdId: row.householdId,
|
||||||
|
senderTelegramUserId: row.senderTelegramUserId,
|
||||||
|
parsedAmountMinor: row.parsedAmountMinor,
|
||||||
|
parsedCurrency:
|
||||||
|
row.parsedCurrency === 'USD' || row.parsedCurrency === 'GEL' ? row.parsedCurrency : null,
|
||||||
|
parsedItemDescription: row.parsedItemDescription,
|
||||||
|
parserConfidence: row.parserConfidence,
|
||||||
|
parserMode: row.parserMode === 'llm' ? 'llm' : null,
|
||||||
|
processingStatus:
|
||||||
|
row.processingStatus === 'pending_confirmation' ||
|
||||||
|
row.processingStatus === 'clarification_needed' ||
|
||||||
|
row.processingStatus === 'ignored_not_purchase' ||
|
||||||
|
row.processingStatus === 'parse_failed' ||
|
||||||
|
row.processingStatus === 'confirmed' ||
|
||||||
|
row.processingStatus === 'cancelled' ||
|
||||||
|
row.processingStatus === 'parsed' ||
|
||||||
|
row.processingStatus === 'needs_review'
|
||||||
|
? row.processingStatus
|
||||||
|
: 'parse_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields {
|
||||||
|
return {
|
||||||
|
parsedAmountMinor: row.parsedAmountMinor,
|
||||||
|
parsedCurrency: row.parsedCurrency,
|
||||||
|
parsedItemDescription: row.parsedItemDescription,
|
||||||
|
parserConfidence: row.parserConfidence,
|
||||||
|
parserMode: row.parserMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyToPurchaseMessage(
|
||||||
|
ctx: Context,
|
||||||
|
text: string,
|
||||||
|
replyMarkup?: {
|
||||||
|
inline_keyboard: Array<
|
||||||
|
Array<{
|
||||||
|
text: string
|
||||||
|
callback_data: string
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const message = ctx.msg
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(text, {
|
||||||
|
reply_parameters: {
|
||||||
|
message_id: message.message_id
|
||||||
|
},
|
||||||
|
...(replyMarkup
|
||||||
|
? {
|
||||||
|
reply_markup: replyMarkup
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
|
||||||
|
const message = ctx.message
|
||||||
|
if (!message || !('text' in message)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.is_topic_message || message.message_thread_id === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderTelegramUserId = ctx.from?.id?.toString()
|
||||||
|
if (!senderTelegramUserId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name]
|
||||||
|
.filter((part) => !!part && part.trim().length > 0)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
const candidate: PurchaseTopicCandidate = {
|
||||||
|
updateId: ctx.update.update_id,
|
||||||
|
chatId: message.chat.id.toString(),
|
||||||
|
messageId: message.message_id.toString(),
|
||||||
|
threadId: message.message_thread_id.toString(),
|
||||||
|
senderTelegramUserId,
|
||||||
|
rawText: message.text,
|
||||||
|
messageSentAt: instantFromEpochSeconds(message.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderDisplayName.length > 0) {
|
||||||
|
candidate.senderDisplayName = senderDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractPurchaseTopicCandidate(
|
export function extractPurchaseTopicCandidate(
|
||||||
@@ -108,10 +380,6 @@ export function resolveConfiguredPurchaseTopicRecord(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsReviewAsInt(value: boolean): number {
|
|
||||||
return value ? 1 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPurchaseMessageRepository(databaseUrl: string): {
|
export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||||
repository: PurchaseMessageIngestionRepository
|
repository: PurchaseMessageIngestionRepository
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
@@ -121,8 +389,129 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
prepare: false
|
prepare: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function getStoredMessage(
|
||||||
|
purchaseMessageId: string
|
||||||
|
): Promise<StoredPurchaseMessageRow | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.purchaseMessages.id,
|
||||||
|
householdId: schema.purchaseMessages.householdId,
|
||||||
|
senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId,
|
||||||
|
parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
|
parsedCurrency: schema.purchaseMessages.parsedCurrency,
|
||||||
|
parsedItemDescription: schema.purchaseMessages.parsedItemDescription,
|
||||||
|
parserConfidence: schema.purchaseMessages.parserConfidence,
|
||||||
|
parserMode: schema.purchaseMessages.parserMode,
|
||||||
|
processingStatus: schema.purchaseMessages.processingStatus
|
||||||
|
})
|
||||||
|
.from(schema.purchaseMessages)
|
||||||
|
.where(eq(schema.purchaseMessages.id, purchaseMessageId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return row ? toStoredPurchaseRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutateProposalStatus(
|
||||||
|
purchaseMessageId: string,
|
||||||
|
actorTelegramUserId: string,
|
||||||
|
targetStatus: 'confirmed' | 'cancelled'
|
||||||
|
): Promise<PurchaseProposalActionResult> {
|
||||||
|
const existing = await getStoredMessage(purchaseMessageId)
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
status: 'not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.senderTelegramUserId !== actorTelegramUserId) {
|
||||||
|
return {
|
||||||
|
status: 'forbidden',
|
||||||
|
householdId: existing.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.processingStatus === targetStatus) {
|
||||||
|
return {
|
||||||
|
status: targetStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
|
||||||
|
purchaseMessageId: existing.id,
|
||||||
|
householdId: existing.householdId,
|
||||||
|
...toProposalFields(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.processingStatus !== 'pending_confirmation') {
|
||||||
|
return {
|
||||||
|
status: 'not_pending',
|
||||||
|
householdId: existing.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.update(schema.purchaseMessages)
|
||||||
|
.set({
|
||||||
|
processingStatus: targetStatus,
|
||||||
|
...(targetStatus === 'confirmed'
|
||||||
|
? {
|
||||||
|
needsReview: 0
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId,
|
||||||
|
parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||||
|
parsedCurrency: schema.purchaseMessages.parsedCurrency,
|
||||||
|
parsedItemDescription: schema.purchaseMessages.parsedItemDescription,
|
||||||
|
parserConfidence: schema.purchaseMessages.parserConfidence,
|
||||||
|
parserMode: schema.purchaseMessages.parserMode,
|
||||||
|
processingStatus: schema.purchaseMessages.processingStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = rows[0]
|
||||||
|
if (!updated) {
|
||||||
|
const reloaded = await getStoredMessage(purchaseMessageId)
|
||||||
|
if (!reloaded) {
|
||||||
|
return {
|
||||||
|
status: 'not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reloaded.processingStatus === 'confirmed' || reloaded.processingStatus === 'cancelled') {
|
||||||
|
return {
|
||||||
|
status:
|
||||||
|
reloaded.processingStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
|
||||||
|
purchaseMessageId: reloaded.id,
|
||||||
|
householdId: reloaded.householdId,
|
||||||
|
...toProposalFields(reloaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'not_pending',
|
||||||
|
householdId: reloaded.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = toStoredPurchaseRow(updated)
|
||||||
|
return {
|
||||||
|
status: targetStatus,
|
||||||
|
purchaseMessageId: stored.id,
|
||||||
|
householdId: stored.householdId,
|
||||||
|
...toProposalFields(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const repository: PurchaseMessageIngestionRepository = {
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
async save(record, llmFallback, defaultCurrency) {
|
async save(record, interpreter, defaultCurrency) {
|
||||||
const matchedMember = await db
|
const matchedMember = await db
|
||||||
.select({ id: schema.members.id })
|
.select({ id: schema.members.id })
|
||||||
.from(schema.members)
|
.from(schema.members)
|
||||||
@@ -137,35 +526,19 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
const senderMemberId = matchedMember[0]?.id ?? null
|
const senderMemberId = matchedMember[0]?.id ?? null
|
||||||
let parserError: string | null = null
|
let parserError: string | null = null
|
||||||
|
|
||||||
const parsed = await parsePurchaseMessage(
|
const interpretation = interpreter
|
||||||
{
|
? await interpreter(record.rawText, {
|
||||||
rawText: record.rawText
|
defaultCurrency: defaultCurrency ?? 'GEL'
|
||||||
},
|
}).catch((error) => {
|
||||||
{
|
parserError = error instanceof Error ? error.message : 'Unknown interpreter error'
|
||||||
...(llmFallback
|
|
||||||
? {
|
|
||||||
llmFallback
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(defaultCurrency
|
|
||||||
? {
|
|
||||||
defaultCurrency
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
).catch((error) => {
|
|
||||||
parserError = error instanceof Error ? error.message : 'Unknown parser error'
|
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const processingStatus =
|
const decision = normalizeInterpretation(
|
||||||
parserError !== null
|
interpretation,
|
||||||
? 'parse_failed'
|
parserError ?? (interpreter ? null : 'Purchase interpreter is unavailable')
|
||||||
: parsed === null
|
)
|
||||||
? 'needs_review'
|
|
||||||
: parsed.needsReview
|
|
||||||
? 'needs_review'
|
|
||||||
: 'parsed'
|
|
||||||
|
|
||||||
const inserted = await db
|
const inserted = await db
|
||||||
.insert(schema.purchaseMessages)
|
.insert(schema.purchaseMessages)
|
||||||
@@ -180,14 +553,14 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
telegramThreadId: record.threadId,
|
telegramThreadId: record.threadId,
|
||||||
telegramUpdateId: String(record.updateId),
|
telegramUpdateId: String(record.updateId),
|
||||||
messageSentAt: instantToDate(record.messageSentAt),
|
messageSentAt: instantToDate(record.messageSentAt),
|
||||||
parsedAmountMinor: parsed?.amountMinor,
|
parsedAmountMinor: decision.parsedAmountMinor,
|
||||||
parsedCurrency: parsed?.currency,
|
parsedCurrency: decision.parsedCurrency,
|
||||||
parsedItemDescription: parsed?.itemDescription,
|
parsedItemDescription: decision.parsedItemDescription,
|
||||||
parserMode: parsed?.parserMode,
|
parserMode: decision.parserMode,
|
||||||
parserConfidence: parsed?.confidence,
|
parserConfidence: decision.parserConfidence,
|
||||||
needsReview: needsReviewAsInt(parsed?.needsReview ?? true),
|
needsReview: needsReviewAsInt(decision.needsReview),
|
||||||
parserError,
|
parserError: decision.parserError,
|
||||||
processingStatus
|
processingStatus: decision.status
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({
|
.onConflictDoNothing({
|
||||||
target: [
|
target: [
|
||||||
@@ -198,21 +571,54 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
})
|
})
|
||||||
.returning({ id: schema.purchaseMessages.id })
|
.returning({ id: schema.purchaseMessages.id })
|
||||||
|
|
||||||
if (inserted.length === 0) {
|
const insertedRow = inserted[0]
|
||||||
|
if (!insertedRow) {
|
||||||
return {
|
return {
|
||||||
status: 'duplicate'
|
status: 'duplicate'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (decision.status) {
|
||||||
|
case 'ignored_not_purchase':
|
||||||
return {
|
return {
|
||||||
status: 'created',
|
status: 'ignored_not_purchase',
|
||||||
processingStatus,
|
purchaseMessageId: insertedRow.id
|
||||||
parsedAmountMinor: parsed?.amountMinor ?? null,
|
|
||||||
parsedCurrency: parsed?.currency ?? null,
|
|
||||||
parsedItemDescription: parsed?.itemDescription ?? null,
|
|
||||||
parserConfidence: parsed?.confidence ?? null,
|
|
||||||
parserMode: parsed?.parserMode ?? null
|
|
||||||
}
|
}
|
||||||
|
case 'clarification_needed':
|
||||||
|
return {
|
||||||
|
status: 'clarification_needed',
|
||||||
|
purchaseMessageId: insertedRow.id,
|
||||||
|
clarificationQuestion: decision.clarificationQuestion,
|
||||||
|
parsedAmountMinor: decision.parsedAmountMinor,
|
||||||
|
parsedCurrency: decision.parsedCurrency,
|
||||||
|
parsedItemDescription: decision.parsedItemDescription,
|
||||||
|
parserConfidence: decision.parserConfidence,
|
||||||
|
parserMode: decision.parserMode
|
||||||
|
}
|
||||||
|
case 'pending_confirmation':
|
||||||
|
return {
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
purchaseMessageId: insertedRow.id,
|
||||||
|
parsedAmountMinor: decision.parsedAmountMinor!,
|
||||||
|
parsedCurrency: decision.parsedCurrency!,
|
||||||
|
parsedItemDescription: decision.parsedItemDescription!,
|
||||||
|
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
|
||||||
|
parserMode: decision.parserMode ?? 'llm'
|
||||||
|
}
|
||||||
|
case 'parse_failed':
|
||||||
|
return {
|
||||||
|
status: 'parse_failed',
|
||||||
|
purchaseMessageId: insertedRow.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirm(purchaseMessageId, actorTelegramUserId) {
|
||||||
|
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'confirmed')
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel(purchaseMessageId, actorTelegramUserId) {
|
||||||
|
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'cancelled')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +632,11 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
|
|
||||||
function formatPurchaseSummary(
|
function formatPurchaseSummary(
|
||||||
locale: BotLocale,
|
locale: BotLocale,
|
||||||
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }>
|
result: {
|
||||||
|
parsedAmountMinor: bigint | null
|
||||||
|
parsedCurrency: 'GEL' | 'USD' | null
|
||||||
|
parsedItemDescription: string | null
|
||||||
|
}
|
||||||
): string {
|
): string {
|
||||||
if (
|
if (
|
||||||
result.parsedAmountMinor === null ||
|
result.parsedAmountMinor === null ||
|
||||||
@@ -240,73 +650,250 @@ function formatPurchaseSummary(
|
|||||||
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
|
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clarificationFallback(locale: BotLocale, result: PurchaseClarificationResult): string {
|
||||||
|
const t = getBotTranslations(locale).purchase
|
||||||
|
|
||||||
|
if (result.parsedAmountMinor === null && result.parsedCurrency === null) {
|
||||||
|
return t.clarificationMissingAmountAndCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.parsedAmountMinor === null) {
|
||||||
|
return t.clarificationMissingAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.parsedCurrency === null) {
|
||||||
|
return t.clarificationMissingCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.parsedItemDescription === null) {
|
||||||
|
return t.clarificationMissingItem
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.clarificationLowConfidence
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPurchaseAcknowledgement(
|
export function buildPurchaseAcknowledgement(
|
||||||
result: PurchaseMessageIngestionResult,
|
result: PurchaseMessageIngestionResult,
|
||||||
locale: BotLocale = 'en'
|
locale: BotLocale = 'en'
|
||||||
): string | null {
|
): string | null {
|
||||||
if (result.status === 'duplicate') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = getBotTranslations(locale).purchase
|
const t = getBotTranslations(locale).purchase
|
||||||
|
|
||||||
switch (result.processingStatus) {
|
switch (result.status) {
|
||||||
case 'parsed':
|
case 'duplicate':
|
||||||
return t.recorded(formatPurchaseSummary(locale, result))
|
case 'ignored_not_purchase':
|
||||||
case 'needs_review':
|
return null
|
||||||
return t.savedForReview(formatPurchaseSummary(locale, result))
|
case 'pending_confirmation':
|
||||||
|
return t.proposal(formatPurchaseSummary(locale, result))
|
||||||
|
case 'clarification_needed':
|
||||||
|
return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result))
|
||||||
case 'parse_failed':
|
case 'parse_failed':
|
||||||
return t.parseFailed
|
return t.parseFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replyToPurchaseMessage(ctx: Context, text: string): Promise<void> {
|
function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) {
|
||||||
const message = ctx.msg
|
const t = getBotTranslations(locale).purchase
|
||||||
if (!message) {
|
|
||||||
|
return {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t.confirmButton,
|
||||||
|
callback_data: `${PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t.cancelButton,
|
||||||
|
callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHouseholdLocale(
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository | undefined,
|
||||||
|
householdId: string
|
||||||
|
): Promise<BotLocale> {
|
||||||
|
if (!householdConfigurationRepository) {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
const householdChat =
|
||||||
|
await householdConfigurationRepository.getHouseholdChatByHouseholdId(householdId)
|
||||||
|
return householdChat?.defaultLocale ?? 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurchaseMessageResult(
|
||||||
|
ctx: Context,
|
||||||
|
record: PurchaseTopicRecord,
|
||||||
|
result: PurchaseMessageIngestionResult,
|
||||||
|
locale: BotLocale,
|
||||||
|
logger: Logger | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
if (result.status !== 'duplicate') {
|
||||||
|
logger?.info(
|
||||||
|
{
|
||||||
|
event: 'purchase.ingested',
|
||||||
|
householdId: record.householdId,
|
||||||
|
status: result.status,
|
||||||
|
chatId: record.chatId,
|
||||||
|
threadId: record.threadId,
|
||||||
|
messageId: record.messageId,
|
||||||
|
updateId: record.updateId,
|
||||||
|
senderTelegramUserId: record.senderTelegramUserId
|
||||||
|
},
|
||||||
|
'Purchase topic message processed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acknowledgement = buildPurchaseAcknowledgement(result, locale)
|
||||||
|
if (!acknowledgement) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(text, {
|
await replyToPurchaseMessage(
|
||||||
reply_parameters: {
|
ctx,
|
||||||
message_id: message.message_id
|
acknowledgement,
|
||||||
|
result.status === 'pending_confirmation'
|
||||||
|
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId)
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyInlineKeyboard() {
|
||||||
|
return {
|
||||||
|
inline_keyboard: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseActionMessage(
|
||||||
|
locale: BotLocale,
|
||||||
|
result: Extract<
|
||||||
|
PurchaseProposalActionResult,
|
||||||
|
{ status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' }
|
||||||
|
>
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(locale).purchase
|
||||||
|
const summary = formatPurchaseSummary(locale, result)
|
||||||
|
|
||||||
|
if (result.status === 'confirmed' || result.status === 'already_confirmed') {
|
||||||
|
return t.confirmed(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.cancelled(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPurchaseProposalCallbacks(
|
||||||
|
bot: Bot,
|
||||||
|
repository: PurchaseMessageIngestionRepository,
|
||||||
|
resolveLocale: (householdId: string) => Promise<BotLocale>,
|
||||||
|
logger?: Logger
|
||||||
|
): void {
|
||||||
|
bot.callbackQuery(new RegExp(`^${PURCHASE_CONFIRM_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
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await repository.confirm(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 === 'confirmed' ? t.confirmedToast : t.alreadyConfirmed
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ctx.msg) {
|
||||||
|
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
|
||||||
|
reply_markup: emptyInlineKeyboard()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
|
logger?.info(
|
||||||
const message = ctx.message
|
{
|
||||||
if (!message || !('text' in message)) {
|
event: 'purchase.confirmation',
|
||||||
return null
|
purchaseMessageId,
|
||||||
|
actorTelegramUserId,
|
||||||
|
status: result.status
|
||||||
|
},
|
||||||
|
'Purchase proposal confirmation handled'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
bot.callbackQuery(new RegExp(`^${PURCHASE_CANCEL_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 (!message.is_topic_message || message.message_thread_id === undefined) {
|
const result = await repository.cancel(purchaseMessageId, actorTelegramUserId)
|
||||||
return null
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderTelegramUserId = ctx.from?.id?.toString()
|
if (result.status === 'forbidden') {
|
||||||
if (!senderTelegramUserId) {
|
await ctx.answerCallbackQuery({
|
||||||
return null
|
text: t.notYourProposal,
|
||||||
|
show_alert: true
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name]
|
await ctx.answerCallbackQuery({
|
||||||
.filter((part) => !!part && part.trim().length > 0)
|
text: result.status === 'cancelled' ? t.cancelledToast : t.alreadyCancelled
|
||||||
.join(' ')
|
})
|
||||||
|
|
||||||
const candidate: PurchaseTopicCandidate = {
|
if (ctx.msg) {
|
||||||
updateId: ctx.update.update_id,
|
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
|
||||||
chatId: message.chat.id.toString(),
|
reply_markup: emptyInlineKeyboard()
|
||||||
messageId: message.message_id.toString(),
|
})
|
||||||
threadId: message.message_thread_id.toString(),
|
|
||||||
senderTelegramUserId,
|
|
||||||
rawText: message.text,
|
|
||||||
messageSentAt: instantFromEpochSeconds(message.date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (senderDisplayName.length > 0) {
|
logger?.info(
|
||||||
candidate.senderDisplayName = senderDisplayName
|
{
|
||||||
}
|
event: 'purchase.cancellation',
|
||||||
|
purchaseMessageId,
|
||||||
return candidate
|
actorTelegramUserId,
|
||||||
|
status: result.status
|
||||||
|
},
|
||||||
|
'Purchase proposal cancellation handled'
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerPurchaseTopicIngestion(
|
export function registerPurchaseTopicIngestion(
|
||||||
@@ -314,10 +901,12 @@ export function registerPurchaseTopicIngestion(
|
|||||||
config: PurchaseTopicIngestionConfig,
|
config: PurchaseTopicIngestionConfig,
|
||||||
repository: PurchaseMessageIngestionRepository,
|
repository: PurchaseMessageIngestionRepository,
|
||||||
options: {
|
options: {
|
||||||
llmFallback?: PurchaseParserLlmFallback
|
interpreter?: PurchaseMessageInterpreter
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
} = {}
|
} = {}
|
||||||
): void {
|
): void {
|
||||||
|
void registerPurchaseProposalCallbacks(bot, repository, async () => 'en', options.logger)
|
||||||
|
|
||||||
bot.on('message:text', async (ctx, next) => {
|
bot.on('message:text', async (ctx, next) => {
|
||||||
const candidate = toCandidateFromContext(ctx)
|
const candidate = toCandidateFromContext(ctx)
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -332,27 +921,8 @@ export function registerPurchaseTopicIngestion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await repository.save(record, options.llmFallback, 'GEL')
|
const result = await repository.save(record, options.interpreter, 'GEL')
|
||||||
const acknowledgement = buildPurchaseAcknowledgement(status, 'en')
|
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger)
|
||||||
|
|
||||||
if (status.status === 'created') {
|
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'purchase.ingested',
|
|
||||||
processingStatus: status.processingStatus,
|
|
||||||
chatId: record.chatId,
|
|
||||||
threadId: record.threadId,
|
|
||||||
messageId: record.messageId,
|
|
||||||
updateId: record.updateId,
|
|
||||||
senderTelegramUserId: record.senderTelegramUserId
|
|
||||||
},
|
|
||||||
'Purchase topic message ingested'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acknowledgement) {
|
|
||||||
await replyToPurchaseMessage(ctx, acknowledgement)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
{
|
{
|
||||||
@@ -374,10 +944,17 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
householdConfigurationRepository: HouseholdConfigurationRepository,
|
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||||
repository: PurchaseMessageIngestionRepository,
|
repository: PurchaseMessageIngestionRepository,
|
||||||
options: {
|
options: {
|
||||||
llmFallback?: PurchaseParserLlmFallback
|
interpreter?: PurchaseMessageInterpreter
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
} = {}
|
} = {}
|
||||||
): void {
|
): void {
|
||||||
|
void registerPurchaseProposalCallbacks(
|
||||||
|
bot,
|
||||||
|
repository,
|
||||||
|
async (householdId) => resolveHouseholdLocale(householdConfigurationRepository, householdId),
|
||||||
|
options.logger
|
||||||
|
)
|
||||||
|
|
||||||
bot.on('message:text', async (ctx, next) => {
|
bot.on('message:text', async (ctx, next) => {
|
||||||
const candidate = toCandidateFromContext(ctx)
|
const candidate = toCandidateFromContext(ctx)
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -405,38 +982,17 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings(
|
||||||
record.householdId
|
record.householdId
|
||||||
)
|
)
|
||||||
const status = await repository.save(
|
const locale = await resolveHouseholdLocale(
|
||||||
record,
|
householdConfigurationRepository,
|
||||||
options.llmFallback,
|
|
||||||
billingSettings.settlementCurrency
|
|
||||||
)
|
|
||||||
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
|
||||||
record.householdId
|
record.householdId
|
||||||
)
|
)
|
||||||
const acknowledgement = buildPurchaseAcknowledgement(
|
const result = await repository.save(
|
||||||
status,
|
record,
|
||||||
householdChat?.defaultLocale ?? 'en'
|
options.interpreter,
|
||||||
|
billingSettings.settlementCurrency
|
||||||
)
|
)
|
||||||
|
|
||||||
if (status.status === 'created') {
|
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger)
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'purchase.ingested',
|
|
||||||
householdId: record.householdId,
|
|
||||||
processingStatus: status.processingStatus,
|
|
||||||
chatId: record.chatId,
|
|
||||||
threadId: record.threadId,
|
|
||||||
messageId: record.messageId,
|
|
||||||
updateId: record.updateId,
|
|
||||||
senderTelegramUserId: record.senderTelegramUserId
|
|
||||||
},
|
|
||||||
'Purchase topic message ingested'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acknowledgement) {
|
|
||||||
await replyToPurchaseMessage(ctx, acknowledgement)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export function createDbFinanceRepository(
|
|||||||
parsedCurrency: input.currency,
|
parsedCurrency: input.currency,
|
||||||
parsedItemDescription: input.description,
|
parsedItemDescription: input.description,
|
||||||
needsReview: 0,
|
needsReview: 0,
|
||||||
processingStatus: 'parsed',
|
processingStatus: 'confirmed',
|
||||||
parserError: null
|
parserError: null
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
@@ -597,6 +597,10 @@ export function createDbFinanceRepository(
|
|||||||
isNotNull(schema.purchaseMessages.senderMemberId),
|
isNotNull(schema.purchaseMessages.senderMemberId),
|
||||||
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
||||||
isNotNull(schema.purchaseMessages.parsedCurrency),
|
isNotNull(schema.purchaseMessages.parsedCurrency),
|
||||||
|
or(
|
||||||
|
eq(schema.purchaseMessages.processingStatus, 'parsed'),
|
||||||
|
eq(schema.purchaseMessages.processingStatus, 'confirmed')
|
||||||
|
),
|
||||||
gte(schema.purchaseMessages.messageSentAt, instantToDate(start)),
|
gte(schema.purchaseMessages.messageSentAt, instantToDate(start)),
|
||||||
lt(schema.purchaseMessages.messageSentAt, instantToDate(end))
|
lt(schema.purchaseMessages.messageSentAt, instantToDate(end))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const server = {
|
|||||||
.transform((value) => parseOptionalCsv(value)),
|
.transform((value) => parseOptionalCsv(value)),
|
||||||
OPENAI_API_KEY: z.string().min(1).optional(),
|
OPENAI_API_KEY: z.string().min(1).optional(),
|
||||||
PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'),
|
PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'),
|
||||||
|
PURCHASE_PARSER_MODEL: z.string().min(1).default('gpt-5-mini'),
|
||||||
SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
|
SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user