fix(bot): improve purchase-topic conversation flow

This commit is contained in:
2026-03-12 18:23:26 +04:00
parent 8374d18189
commit 5ebae7714c
6 changed files with 386 additions and 32 deletions

View File

@@ -436,6 +436,12 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedAmountMinor: bigint
parsedCurrency: 'GEL' | 'USD'
parsedItemDescription: string
participants: readonly {
id: string
memberId: string
displayName: string
included: boolean
}[]
status: 'pending_confirmation' | 'confirmed' | 'cancelled'
}
>()
@@ -458,6 +464,14 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'door handle',
participants: [
{
id: 'participant-1',
memberId: 'member-1',
displayName: 'Mia',
included: true
}
],
status: 'pending_confirmation'
})
@@ -502,6 +516,14 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedAmountMinor: 4500n,
parsedCurrency: 'GEL',
parsedItemDescription: 'sausages',
participants: [
{
id: 'participant-1',
memberId: 'member-1',
displayName: 'Mia',
included: true
}
],
status: 'pending_confirmation'
})
@@ -553,7 +575,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedCurrency: proposal.parsedCurrency,
parsedItemDescription: proposal.parsedItemDescription,
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: proposal.participants
}
}
@@ -573,7 +596,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedCurrency: proposal.parsedCurrency,
parsedItemDescription: proposal.parsedItemDescription,
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: proposal.participants
}
},
async cancel(purchaseMessageId, actorTelegramUserId) {
@@ -600,7 +624,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedCurrency: proposal.parsedCurrency,
parsedItemDescription: proposal.parsedItemDescription,
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: proposal.participants
}
}
@@ -620,7 +645,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
parsedCurrency: proposal.parsedCurrency,
parsedItemDescription: proposal.parsedItemDescription,
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: proposal.participants
}
},
async toggleParticipant() {

View File

@@ -32,7 +32,11 @@ import type {
PurchaseTopicRecord
} from './purchase-topic-ingestion'
import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router'
import { fallbackTopicMessageRoute, getCachedTopicMessageRoute } from './topic-message-router'
import {
fallbackTopicMessageRoute,
getCachedTopicMessageRoute,
looksLikeDirectBotAddress
} from './topic-message-router'
import { startTypingIndicator } from './telegram-chat-action'
import { stripExplicitBotMention } from './telegram-mentions'
@@ -1129,8 +1133,11 @@ export function registerDmAssistant(options: {
}
const mention = stripExplicitBotMention(ctx)
const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text)
const isAddressed = Boolean(
(mention && mention.strippedText.length > 0) || isReplyToBotMessage(ctx)
(mention && mention.strippedText.length > 0) ||
directAddressByText ||
isReplyToBotMessage(ctx)
)
const telegramUserId = ctx.from?.id?.toString()
@@ -1234,7 +1241,7 @@ export function registerDmAssistant(options: {
locale,
topicRole,
messageText,
isExplicitMention: Boolean(mention),
isExplicitMention: Boolean(mention) || directAddressByText,
isReplyToBot: isReplyToBotMessage(ctx),
assistantContext: assistantConfig.assistantContext,
assistantTone: assistantConfig.assistantTone,

View File

@@ -22,6 +22,7 @@ import {
import {
cacheTopicMessageRoute,
getCachedTopicMessageRoute,
looksLikeDirectBotAddress,
type TopicMessageRouter
} from './topic-message-router'
import { stripExplicitBotMention } from './telegram-mentions'
@@ -252,7 +253,7 @@ async function routePaymentTopicMessage(input: {
locale: input.locale,
topicRole: input.topicRole,
messageText: input.record.rawText,
isExplicitMention: input.isExplicitMention,
isExplicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText),
isReplyToBot: input.isReplyToBot,
activeWorkflow: input.activeWorkflow,
assistantContext: input.assistantContext,

View File

@@ -1,11 +1,14 @@
import { describe, expect, test } from 'bun:test'
import { instantFromIso } from '@household/domain'
import type { HouseholdConfigurationRepository } from '@household/ports'
import { createTelegramBot } from './bot'
import { createInMemoryAssistantConversationMemoryStore } from './assistant-state'
import {
buildPurchaseAcknowledgement,
extractPurchaseTopicCandidate,
registerConfiguredPurchaseTopicIngestion,
registerPurchaseTopicIngestion,
resolveConfiguredPurchaseTopicRecord,
type PurchaseMessageIngestionRepository,
@@ -52,6 +55,7 @@ function purchaseUpdate(
text: string,
options: {
replyToBot?: boolean
threadId?: number
} = {}
) {
const commandToken = text.split(' ')[0] ?? text
@@ -61,7 +65,7 @@ function purchaseUpdate(
message: {
message_id: 55,
date: Math.floor(Date.now() / 1000),
message_thread_id: 777,
message_thread_id: options.threadId ?? 777,
is_topic_message: true,
chat: {
id: Number(config.householdChatId),
@@ -1660,6 +1664,260 @@ Confirm or cancel below.`,
})
})
test('uses recent silent planning context for direct bot-address advice replies', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
const memoryStore = createInMemoryAssistantConversationMemoryStore(12)
let sawDirectAddress = false
let recentTurnTexts: string[] = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
memoryStore,
router: async (input) => {
if (input.messageText.includes('думаю купить')) {
return {
route: 'silent',
replyText: null,
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'planning'
}
}
sawDirectAddress = input.isExplicitMention
recentTurnTexts = input.recentTurns?.map((turn) => turn.text) ?? []
return {
route: 'chat_reply',
replyText: 'Если 5 кг стоят 20 лари, это 4 лари за кило. Я бы еще сравнил цену.',
helperKind: 'assistant',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 92,
reason: 'planning_advice'
}
}
})
await bot.handleUpdate(
purchaseUpdate('В общем, думаю купить 5 килограмм картошки за 20 лари') as never
)
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?') as never)
expect(sawDirectAddress).toBe(true)
expect(recentTurnTexts).toContain('В общем, думаю купить 5 килограмм картошки за 20 лари')
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Если 5 кг стоят 20 лари, это 4 лари за кило. Я бы еще сравнил цену.'
}
})
})
test('does not treat ordinary bot nouns as direct address', 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 hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
router: async (input) => ({
route: input.isExplicitMention ? 'chat_reply' : 'silent',
replyText: input.isExplicitMention ? 'heard you' : null,
helperKind: input.isExplicitMention ? 'assistant' : null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'test'
})
})
await bot.handleUpdate(purchaseUpdate('Думаю купить bot vacuum за 200 лари') as never)
expect(calls).toHaveLength(0)
})
test('keeps silent planning context scoped to the current purchase thread', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
const memoryStore = createInMemoryAssistantConversationMemoryStore(12)
let recentTurnTexts: string[] = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
const householdConfigurationRepository = {
findHouseholdTopicByTelegramContext: async ({
telegramThreadId
}: {
telegramThreadId: string
}) => ({
householdId: config.householdId,
telegramThreadId,
role: 'purchase' as const,
topicName: null
}),
getHouseholdBillingSettings: async () => ({
householdId: config.householdId,
paymentBalanceAdjustmentPolicy: 'utilities' as const,
rentAmountMinor: null,
rentCurrency: 'USD' as const,
rentDueDay: 4,
rentWarningDay: 2,
utilitiesDueDay: 12,
utilitiesReminderDay: 10,
timezone: 'Asia/Tbilisi',
settlementCurrency: 'GEL' as const
}),
getHouseholdChatByHouseholdId: async () => ({
householdId: config.householdId,
householdName: 'Test household',
telegramChatId: config.householdChatId,
telegramChatType: 'supergroup',
title: 'Test household',
defaultLocale: 'en' as const
}),
getHouseholdAssistantConfig: async () => ({
householdId: config.householdId,
assistantContext: null,
assistantTone: null
})
} satisfies Pick<
HouseholdConfigurationRepository,
| 'findHouseholdTopicByTelegramContext'
| 'getHouseholdBillingSettings'
| 'getHouseholdChatByHouseholdId'
| 'getHouseholdAssistantConfig'
>
registerConfiguredPurchaseTopicIngestion(
bot,
householdConfigurationRepository as unknown as HouseholdConfigurationRepository,
repository,
{
memoryStore,
router: async (input) => {
if (input.messageText.includes('картошки')) {
return {
route: 'silent',
replyText: null,
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'planning'
}
}
recentTurnTexts = input.recentTurns?.map((turn) => turn.text) ?? []
return {
route: 'chat_reply',
replyText: 'No leaked context here.',
helperKind: 'assistant',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 91,
reason: 'thread_scoped'
}
}
}
)
await bot.handleUpdate(
purchaseUpdate('Думаю купить 5 килограмм картошки за 20 лари', { threadId: 777 }) as never
)
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?', { threadId: 778 }) as never)
expect(recentTurnTexts).not.toContain('Думаю купить 5 килограмм картошки за 20 лари')
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'No leaked context here.'
}
})
})
test('confirms a pending proposal and edits the bot message', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
@@ -1698,7 +1956,8 @@ Confirm or cancel below.`,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: participants()
}
},
async cancel() {
@@ -1725,7 +1984,11 @@ Confirm or cancel below.`,
payload: {
chat_id: Number(config.householdChatId),
message_id: 77,
text: 'Purchase confirmed: toilet paper - 30.00 GEL',
text: `Purchase confirmed: toilet paper - 30.00 GEL
Participants:
- Mia
- Dima (excluded)`,
reply_markup: {
inline_keyboard: []
}
@@ -1815,7 +2078,8 @@ Confirm or cancel below.`,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: participants()
}
},
async cancel() {
@@ -1839,7 +2103,11 @@ Confirm or cancel below.`,
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Purchase confirmed: toilet paper - 30.00 GEL'
text: `Purchase confirmed: toilet paper - 30.00 GEL
Participants:
- Mia
- Dima (excluded)`
}
})
})
@@ -1876,7 +2144,8 @@ Confirm or cancel below.`,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const
parserMode: 'llm' as const,
participants: participants()
}
},
async toggleParticipant() {

View File

@@ -10,7 +10,6 @@ import type {
import { createDbClient, schema } from '@household/db'
import { getBotTranslations, type BotLocale } from './i18n'
import type { AssistantConversationMemoryStore } from './assistant-state'
import { conversationMemoryKey } from './assistant-state'
import type {
PurchaseInterpretationAmountSource,
PurchaseInterpretation,
@@ -19,6 +18,7 @@ import type {
import {
cacheTopicMessageRoute,
getCachedTopicMessageRoute,
looksLikeDirectBotAddress,
type TopicMessageRouter,
type TopicMessageRoutingResult
} from './topic-message-router'
@@ -135,6 +135,7 @@ export type PurchaseProposalActionResult =
status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled'
purchaseMessageId: string
householdId: string
participants: readonly PurchaseProposalParticipant[]
} & PurchaseProposalFields)
| {
status: 'forbidden'
@@ -844,6 +845,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
status: targetStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
purchaseMessageId: existing.id,
householdId: existing.householdId,
participants: toProposalParticipants(await getStoredParticipants(existing.id)),
...toProposalFields(existing)
}
}
@@ -899,6 +901,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
reloaded.processingStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
purchaseMessageId: reloaded.id,
householdId: reloaded.householdId,
participants: toProposalParticipants(await getStoredParticipants(reloaded.id)),
...toProposalFields(reloaded)
}
}
@@ -914,6 +917,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
status: targetStatus,
purchaseMessageId: stored.id,
householdId: stored.householdId,
participants: toProposalParticipants(await getStoredParticipants(stored.id)),
...toProposalFields(stored)
}
}
@@ -1440,29 +1444,33 @@ async function resolveAssistantConfig(
}
function memoryKeyForRecord(record: PurchaseTopicRecord): string {
return conversationMemoryKey({
telegramUserId: record.senderTelegramUserId,
telegramChatId: record.chatId,
isPrivateChat: false
})
return `group:${record.chatId}:${record.senderTelegramUserId}:thread:${record.threadId}`
}
function appendConversation(
function rememberUserTurn(
memoryStore: AssistantConversationMemoryStore | undefined,
record: PurchaseTopicRecord,
userText: string,
assistantText: string
record: PurchaseTopicRecord
): void {
if (!memoryStore) {
return
}
const key = memoryKeyForRecord(record)
memoryStore.appendTurn(key, {
memoryStore.appendTurn(memoryKeyForRecord(record), {
role: 'user',
text: userText
text: record.rawText
})
memoryStore.appendTurn(key, {
}
function rememberAssistantTurn(
memoryStore: AssistantConversationMemoryStore | undefined,
record: PurchaseTopicRecord,
assistantText: string | null
): void {
if (!memoryStore || !assistantText) {
return
}
memoryStore.appendTurn(memoryKeyForRecord(record), {
role: 'assistant',
text: assistantText
})
@@ -1540,7 +1548,9 @@ async function routePurchaseTopicMessage(input: {
locale: input.locale,
topicRole: 'purchase',
messageText: input.record.rawText,
isExplicitMention: stripExplicitBotMention(input.ctx) !== null,
isExplicitMention:
stripExplicitBotMention(input.ctx) !== null ||
looksLikeDirectBotAddress(input.record.rawText),
isReplyToBot: isReplyToCurrentBot(input.ctx),
activeWorkflow: (await input.repository.hasClarificationContext(input.record))
? 'purchase_clarification'
@@ -1608,9 +1618,11 @@ function buildPurchaseActionMessage(
): string {
const t = getBotTranslations(locale).purchase
const summary = formatPurchaseSummary(locale, result)
const participants =
'participants' in result ? formatPurchaseParticipants(locale, result.participants) : null
if (result.status === 'confirmed' || result.status === 'already_confirmed') {
return t.confirmed(summary)
return participants ? `${t.confirmed(summary)}\n\n${participants}` : t.confirmed(summary)
}
return t.cancelled(summary)
@@ -1912,6 +1924,7 @@ export function registerPurchaseTopicIngestion(
cacheTopicMessageRoute(ctx, 'purchase', route)
if (route.route === 'silent') {
rememberUserTurn(options.memoryStore, record)
await next()
return
}
@@ -1921,9 +1934,10 @@ export function registerPurchaseTopicIngestion(
}
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
rememberUserTurn(options.memoryStore, record)
if (route.replyText) {
await replyToPurchaseMessage(ctx, route.replyText)
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
rememberAssistantTurn(options.memoryStore, record, route.replyText)
}
return
}
@@ -1934,10 +1948,12 @@ export function registerPurchaseTopicIngestion(
}
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
rememberUserTurn(options.memoryStore, record)
await next()
return
}
rememberUserTurn(options.memoryStore, record)
typingIndicator =
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
const pendingReply =
@@ -1950,6 +1966,7 @@ export function registerPurchaseTopicIngestion(
return await next()
}
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply)
rememberAssistantTurn(options.memoryStore, record, buildPurchaseAcknowledgement(result, 'en'))
} catch (error) {
options.logger?.error(
{
@@ -2035,6 +2052,7 @@ export function registerConfiguredPurchaseTopicIngestion(
cacheTopicMessageRoute(ctx, 'purchase', route)
if (route.route === 'silent') {
rememberUserTurn(options.memoryStore, record)
await next()
return
}
@@ -2044,9 +2062,10 @@ export function registerConfiguredPurchaseTopicIngestion(
}
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
rememberUserTurn(options.memoryStore, record)
if (route.replyText) {
await replyToPurchaseMessage(ctx, route.replyText)
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
rememberAssistantTurn(options.memoryStore, record, route.replyText)
}
return
}
@@ -2057,10 +2076,12 @@ export function registerConfiguredPurchaseTopicIngestion(
}
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
rememberUserTurn(options.memoryStore, record)
await next()
return
}
rememberUserTurn(options.memoryStore, record)
typingIndicator =
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
const pendingReply =
@@ -2081,6 +2102,11 @@ export function registerConfiguredPurchaseTopicIngestion(
}
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
rememberAssistantTurn(
options.memoryStore,
record,
buildPurchaseAcknowledgement(result, locale)
)
} catch (error) {
options.logger?.error(
{

View File

@@ -69,6 +69,12 @@ const LIKELY_PURCHASE_PATTERN =
const LIKELY_PAYMENT_PATTERN =
/\b(?:paid rent|paid utilities|rent paid|utilities paid)\b|(?:^|[^\p{L}])(?:оплатил(?:а|и)?|заплатил(?:а|и)?)(?=$|[^\p{L}])/iu
const LETTER_PATTERN = /\p{L}/u
const DIRECT_BOT_ADDRESS_PATTERN =
/^\s*(?:(?:ну|эй|слышь|слушай|hey|yo)\s*,?\s*)*(?:бот|bot)(?=$|[^\p{L}])/iu
export function looksLikeDirectBotAddress(text: string): boolean {
return DIRECT_BOT_ADDRESS_PATTERN.test(text.trim())
}
function normalizeRoute(value: string): TopicMessageRoute {
return value === 'chat_reply' ||
@@ -154,6 +160,21 @@ export function fallbackTopicMessageRoute(
}
}
if (isAddressed && PLANNING_PATTERN.test(normalized)) {
return {
route: 'chat_reply',
replyText:
input.locale === 'ru'
? 'Похоже, ты пока прикидываешь. Когда захочешь мнение или реальную покупку записать, подключусь.'
: "Sounds like you're still thinking it through. If you want an opinion or a real purchase recorded, I'm in.",
helperKind: 'assistant',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 66,
reason: 'planning_advice'
}
}
if (!PLANNING_PATTERN.test(normalized) && LIKELY_PURCHASE_PATTERN.test(normalized)) {
return {
route: 'purchase_candidate',
@@ -282,6 +303,9 @@ export function createOpenAiTopicMessageRouter(
'Do not start purchase or payment workflows for planning, hypotheticals, negotiations, tests, or obvious jokes.',
'Treat “stop”, “leave me alone”, “just thinking”, “not a purchase”, and similar messages as backoff or dismissal signals.',
'When the user directly addresses the bot with small talk, joking, or testing, prefer chat_reply with one short sentence.',
'In a purchase topic, if the user is discussing a possible future purchase and asks for an opinion, prefer chat_reply with a short contextual opinion instead of a workflow.',
'Use the recent conversation when writing replyText. Do not ignore the already-established subject.',
'If the user asks what you think about a price or quantity, mention the actual item/price from context when possible.',
'Use topic_helper only when the message is a real question or request that likely needs household knowledge or a topic-specific helper.',
'Use purchase_candidate only for a clear completed shared purchase.',
'Use purchase_followup only when there is active purchase clarification and the latest message looks like a real answer to it.',
@@ -305,6 +329,7 @@ export function createOpenAiTopicMessageRouter(
`Topic role: ${input.topicRole}`,
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
`Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`,
`Active workflow: ${input.activeWorkflow ?? 'none'}`,
buildRecentTurns(input),
`Latest message:\n${input.messageText}`