diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index a8f128d..e920823 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -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() { diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 320ff7a..c88ff1a 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -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, diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts index 03d9c3a..f09a21f 100644 --- a/apps/bot/src/payment-topic-ingestion.ts +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -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, diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index f93e1df..ac71b41 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -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() { diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 1a37a10..8eec66b 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -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( { diff --git a/apps/bot/src/topic-message-router.ts b/apps/bot/src/topic-message-router.ts index a9ea96c..6df2568 100644 --- a/apps/bot/src/topic-message-router.ts +++ b/apps/bot/src/topic-message-router.ts @@ -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}`