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

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { instantFromIso } from '@household/domain' import { instantFromIso } from '@household/domain'
import type { HouseholdConfigurationRepository } from '@household/ports'
import { createTelegramBot } from './bot' import { createTelegramBot } from './bot'
import { createInMemoryAssistantConversationMemoryStore } from './assistant-state'
import { import {
buildPurchaseAcknowledgement, buildPurchaseAcknowledgement,
extractPurchaseTopicCandidate, extractPurchaseTopicCandidate,
registerConfiguredPurchaseTopicIngestion,
registerPurchaseTopicIngestion, registerPurchaseTopicIngestion,
resolveConfiguredPurchaseTopicRecord, resolveConfiguredPurchaseTopicRecord,
type PurchaseMessageIngestionRepository, type PurchaseMessageIngestionRepository,
@@ -52,6 +55,7 @@ function purchaseUpdate(
text: string, text: string,
options: { options: {
replyToBot?: boolean replyToBot?: boolean
threadId?: number
} = {} } = {}
) { ) {
const commandToken = text.split(' ')[0] ?? text const commandToken = text.split(' ')[0] ?? text
@@ -61,7 +65,7 @@ function purchaseUpdate(
message: { message: {
message_id: 55, message_id: 55,
date: Math.floor(Date.now() / 1000), date: Math.floor(Date.now() / 1000),
message_thread_id: 777, message_thread_id: options.threadId ?? 777,
is_topic_message: true, is_topic_message: true,
chat: { chat: {
id: Number(config.householdChatId), 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 () => { test('confirms a pending proposal and edits the bot message', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []
@@ -1698,7 +1956,8 @@ Confirm or cancel below.`,
parsedCurrency: 'GEL' as const, parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
parserConfidence: 92, parserConfidence: 92,
parserMode: 'llm' as const parserMode: 'llm' as const,
participants: participants()
} }
}, },
async cancel() { async cancel() {
@@ -1725,7 +1984,11 @@ Confirm or cancel below.`,
payload: { payload: {
chat_id: Number(config.householdChatId), chat_id: Number(config.householdChatId),
message_id: 77, 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: { reply_markup: {
inline_keyboard: [] inline_keyboard: []
} }
@@ -1815,7 +2078,8 @@ Confirm or cancel below.`,
parsedCurrency: 'GEL' as const, parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
parserConfidence: 92, parserConfidence: 92,
parserMode: 'llm' as const parserMode: 'llm' as const,
participants: participants()
} }
}, },
async cancel() { async cancel() {
@@ -1839,7 +2103,11 @@ Confirm or cancel below.`,
expect(calls[1]).toMatchObject({ expect(calls[1]).toMatchObject({
method: 'editMessageText', method: 'editMessageText',
payload: { 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, parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
parserConfidence: 92, parserConfidence: 92,
parserMode: 'llm' as const parserMode: 'llm' as const,
participants: participants()
} }
}, },
async toggleParticipant() { async toggleParticipant() {

View File

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

View File

@@ -69,6 +69,12 @@ const LIKELY_PURCHASE_PATTERN =
const LIKELY_PAYMENT_PATTERN = const LIKELY_PAYMENT_PATTERN =
/\b(?:paid rent|paid utilities|rent paid|utilities paid)\b|(?:^|[^\p{L}])(?:оплатил(?:а|и)?|заплатил(?:а|и)?)(?=$|[^\p{L}])/iu /\b(?:paid rent|paid utilities|rent paid|utilities paid)\b|(?:^|[^\p{L}])(?:оплатил(?:а|и)?|заплатил(?:а|и)?)(?=$|[^\p{L}])/iu
const LETTER_PATTERN = /\p{L}/u 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 { function normalizeRoute(value: string): TopicMessageRoute {
return value === 'chat_reply' || 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)) { if (!PLANNING_PATTERN.test(normalized) && LIKELY_PURCHASE_PATTERN.test(normalized)) {
return { return {
route: 'purchase_candidate', 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.', '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.', '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.', '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 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_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.', '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}`, `Topic role: ${input.topicRole}`,
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`, `Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`, `Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
`Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`,
`Active workflow: ${input.activeWorkflow ?? 'none'}`, `Active workflow: ${input.activeWorkflow ?? 'none'}`,
buildRecentTurns(input), buildRecentTurns(input),
`Latest message:\n${input.messageText}` `Latest message:\n${input.messageText}`