diff --git a/apps/bot/src/assistant-composer.ts b/apps/bot/src/assistant-composer.ts new file mode 100644 index 0000000..56405a9 --- /dev/null +++ b/apps/bot/src/assistant-composer.ts @@ -0,0 +1,79 @@ +import type { Logger } from '@household/observability' + +import type { ConversationalAssistant } from './openai-chat-assistant' +import type { TopicMessageRole } from './topic-message-router' + +export async function composeAssistantReplyText(input: { + assistant: ConversationalAssistant | undefined + locale: 'en' | 'ru' + topicRole: TopicMessageRole + householdContext: string + userMessage: string + recentTurns: readonly { + role: 'user' | 'assistant' + text: string + }[] + recentThreadMessages?: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] + recentChatMessages?: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] + authoritativeFacts?: readonly string[] + responseInstructions?: string | null + fallbackText: string + logger: Logger | undefined + logEvent: string +}): Promise { + if (!input.assistant) { + return input.fallbackText + } + + const logger = input.logger + + try { + const responseInput: Parameters[0] = { + locale: input.locale, + topicRole: input.topicRole, + householdContext: input.householdContext, + memorySummary: null, + recentTurns: input.recentTurns, + userMessage: input.userMessage + } + + if (input.authoritativeFacts) { + responseInput.authoritativeFacts = input.authoritativeFacts + } + + if (input.recentThreadMessages) { + responseInput.recentThreadMessages = input.recentThreadMessages + } + + if (input.recentChatMessages) { + responseInput.sameDayChatMessages = input.recentChatMessages + } + + if (input.responseInstructions) { + responseInput.responseInstructions = input.responseInstructions + } + + const reply = await input.assistant.respond(responseInput) + + return reply.text + } catch (error) { + logger?.warn( + { + event: input.logEvent, + error + }, + 'Assistant-composed reply failed, falling back' + ) + return input.fallbackText + } +} diff --git a/apps/bot/src/conversation-orchestrator.test.ts b/apps/bot/src/conversation-orchestrator.test.ts new file mode 100644 index 0000000..1063e9d --- /dev/null +++ b/apps/bot/src/conversation-orchestrator.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from 'bun:test' + +import { instantFromIso } from '@household/domain' +import type { TopicMessageHistoryRecord, TopicMessageHistoryRepository } from '@household/ports' + +import { createInMemoryAssistantConversationMemoryStore } from './assistant-state' +import { buildConversationContext } from './conversation-orchestrator' + +function createTopicMessageHistoryRepository( + rows: readonly TopicMessageHistoryRecord[] +): TopicMessageHistoryRepository { + return { + async saveMessage() {}, + async listRecentThreadMessages(input) { + return rows + .filter( + (row) => + row.householdId === input.householdId && + row.telegramChatId === input.telegramChatId && + row.telegramThreadId === input.telegramThreadId + ) + .slice(-input.limit) + }, + async listRecentChatMessages(input) { + return rows + .filter( + (row) => + row.householdId === input.householdId && + row.telegramChatId === input.telegramChatId && + row.messageSentAt !== null && + row.messageSentAt.epochMilliseconds >= input.sentAtOrAfter.epochMilliseconds + ) + .slice(-input.limit) + } + } +} + +function historyRecord( + rawText: string, + overrides: Partial = {} +): TopicMessageHistoryRecord { + return { + householdId: 'household-1', + telegramChatId: '-100123', + telegramThreadId: '777', + telegramMessageId: '1', + telegramUpdateId: '1', + senderTelegramUserId: overrides.isBot ? '999000' : '123456', + senderDisplayName: overrides.isBot ? 'Kojori Bot' : 'Stas', + isBot: false, + rawText, + messageSentAt: instantFromIso('2026-03-12T12:00:00.000Z'), + ...overrides + } +} + +async function buildTestContext(input: { + repositoryRows: readonly TopicMessageHistoryRecord[] + messageText: string + explicitMention?: boolean + replyToBot?: boolean + directBotAddress?: boolean + referenceInstant?: ReturnType +}) { + const contextInput: Parameters[0] = { + repository: createTopicMessageHistoryRepository(input.repositoryRows), + householdId: 'household-1', + telegramChatId: '-100123', + telegramThreadId: '777', + telegramUserId: '123456', + topicRole: 'generic', + activeWorkflow: null, + messageText: input.messageText, + explicitMention: input.explicitMention ?? false, + replyToBot: input.replyToBot ?? false, + directBotAddress: input.directBotAddress ?? false, + memoryStore: createInMemoryAssistantConversationMemoryStore(12) + } + + if (input.referenceInstant) { + contextInput.referenceInstant = input.referenceInstant + } + + return buildConversationContext(contextInput) +} + +describe('buildConversationContext', () => { + test('keeps reply-to-bot engagement even after the weak-session ttl', async () => { + const context = await buildTestContext({ + repositoryRows: [ + historyRecord('Какую именно рыбу ты хочешь купить?', { + isBot: true, + senderTelegramUserId: '999000', + senderDisplayName: 'Kojori Bot', + messageSentAt: instantFromIso('2026-03-12T11:20:00.000Z') + }) + ], + messageText: 'Лосось', + replyToBot: true, + referenceInstant: instantFromIso('2026-03-12T12:00:00.000Z') + }) + + expect(context.engagement).toMatchObject({ + engaged: true, + reason: 'reply_to_bot' + }) + }) + + test('uses weak-session fallback only while the recent bot turn is still fresh', async () => { + const recentContext = await buildTestContext({ + repositoryRows: [ + historyRecord('Ты как?', { + messageSentAt: instantFromIso('2026-03-12T11:49:00.000Z') + }), + historyRecord('Я тут.', { + isBot: true, + senderTelegramUserId: '999000', + senderDisplayName: 'Kojori Bot', + messageSentAt: instantFromIso('2026-03-12T11:50:00.000Z') + }) + ], + messageText: 'И что дальше', + referenceInstant: instantFromIso('2026-03-12T12:00:00.000Z') + }) + const expiredContext = await buildTestContext({ + repositoryRows: [ + historyRecord('Ты как?', { + messageSentAt: instantFromIso('2026-03-12T11:19:00.000Z') + }), + historyRecord('Я тут.', { + isBot: true, + senderTelegramUserId: '999000', + senderDisplayName: 'Kojori Bot', + messageSentAt: instantFromIso('2026-03-12T11:20:00.000Z') + }) + ], + messageText: 'И что дальше', + referenceInstant: instantFromIso('2026-03-12T12:00:00.000Z') + }) + + expect(recentContext.engagement).toMatchObject({ + engaged: true, + reason: 'weak_session', + weakSessionActive: true + }) + expect(expiredContext.engagement).toMatchObject({ + engaged: false, + reason: 'none', + weakSessionActive: false + }) + }) + + test('treats a recent open bot question as context, not an unconditional engagement trigger', async () => { + const context = await buildTestContext({ + repositoryRows: [ + historyRecord('Что по рыбе?', { + messageSentAt: instantFromIso('2026-03-12T11:19:00.000Z') + }), + historyRecord('Какую именно рыбу ты хочешь купить?', { + isBot: true, + senderTelegramUserId: '999000', + senderDisplayName: 'Kojori Bot', + messageSentAt: instantFromIso('2026-03-12T11:20:00.000Z') + }) + ], + messageText: 'Сегодня солнце', + referenceInstant: instantFromIso('2026-03-12T12:00:00.000Z') + }) + + expect(context.engagement).toMatchObject({ + engaged: false, + reason: 'open_bot_question', + hasOpenBotQuestion: true, + lastBotQuestion: 'Какую именно рыбу ты хочешь купить?' + }) + }) + + test('reopens engagement for strong contextual references when bot context exists', async () => { + const context = await buildTestContext({ + repositoryRows: [ + historyRecord('Что по рыбе?', { + messageSentAt: instantFromIso('2026-03-12T11:19:00.000Z') + }), + historyRecord('Какую именно рыбу ты хочешь купить?', { + isBot: true, + senderTelegramUserId: '999000', + senderDisplayName: 'Kojori Bot', + messageSentAt: instantFromIso('2026-03-12T11:20:00.000Z') + }) + ], + messageText: 'Вопрос выше, я уже ответил', + referenceInstant: instantFromIso('2026-03-12T12:00:00.000Z') + }) + + expect(context.engagement).toMatchObject({ + engaged: true, + reason: 'strong_reference', + strongReference: true + }) + }) + + test('does not inherit weak-session engagement from another topic participant', async () => { + const context = await buildTestContext({ + repositoryRows: [ + historyRecord('Бот, как жизнь?', { + senderTelegramUserId: '222222', + senderDisplayName: 'Dima', + messageSentAt: instantFromIso('2026-03-12T11:49:00.000Z') + }), + historyRecord('Still standing.', { + isBot: true, + senderTelegramUserId: '999000', + senderDisplayName: 'Kojori Bot', + messageSentAt: instantFromIso('2026-03-12T11:50:00.000Z') + }) + ], + messageText: 'Окей', + referenceInstant: instantFromIso('2026-03-12T12:00:00.000Z') + }) + + expect(context.engagement).toMatchObject({ + engaged: false, + reason: 'none', + weakSessionActive: false + }) + }) + + test('keeps rolling history across local midnight boundaries', async () => { + const context = await buildTestContext({ + repositoryRows: [ + historyRecord('Поздний вечерний контекст', { + messageSentAt: instantFromIso('2026-03-12T19:50:00.000Z') + }), + historyRecord('Уже слишком старое сообщение', { + messageSentAt: instantFromIso('2026-03-11T19:00:00.000Z') + }) + ], + messageText: 'Бот, что происходило в чате?', + directBotAddress: true, + referenceInstant: instantFromIso('2026-03-12T20:30:00.000Z') + }) + + expect(context.rollingChatMessages.map((message) => message.text)).toContain( + 'Поздний вечерний контекст' + ) + expect(context.rollingChatMessages.map((message) => message.text)).not.toContain( + 'Уже слишком старое сообщение' + ) + expect(context.shouldLoadExpandedContext).toBe(true) + }) +}) diff --git a/apps/bot/src/conversation-orchestrator.ts b/apps/bot/src/conversation-orchestrator.ts new file mode 100644 index 0000000..1b534d8 --- /dev/null +++ b/apps/bot/src/conversation-orchestrator.ts @@ -0,0 +1,335 @@ +import { Temporal, nowInstant, type Instant } from '@household/domain' +import type { TopicMessageHistoryRecord, TopicMessageHistoryRepository } from '@household/ports' + +import type { AssistantConversationMemoryStore } from './assistant-state' +import { conversationMemoryKey } from './assistant-state' +import { type TopicMessageRole, type TopicWorkflowState } from './topic-message-router' + +const ROLLING_CONTEXT_WINDOW_MS = 24 * 60 * 60_000 +const WEAK_SESSION_TTL_MS = 20 * 60_000 +const STRONG_CONTEXT_REFERENCE_PATTERN = + /\b(?:question above|already said(?: above)?|you did not answer|from the dialog(?:ue)?|based on the dialog(?:ue)?)\b|(?:^|[^\p{L}])(?:вопрос\s+выше|выше|я\s+уже\s+ответил|я\s+уже\s+сказал|ты\s+не\s+ответил|ответь|контекст(?:\s+диалога)?|основываясь\s+на\s+диалоге)(?=$|[^\p{L}])/iu +const SUMMARY_REQUEST_PATTERN = + /\b(?:summarize|summary|what happened in (?:the )?chat|what were we talking about|what did we say|what did i want to buy|what am i thinking about)\b|(?:^|[^\p{L}])(?:сводк|что\s+происходило\s+в\s+чате|о\s+чем\s+мы\s+говорили|о\s+чем\s+была\s+речь|что\s+я\s+хотел\s+купить|о\s+чем\s+я\s+думаю)(?=$|[^\p{L}])/iu + +export interface ConversationHistoryMessage { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + senderTelegramUserId: string | null + isBot: boolean + messageSentAt: Instant | null +} + +export interface EngagementAssessment { + engaged: boolean + reason: + | 'explicit_mention' + | 'reply_to_bot' + | 'active_workflow' + | 'strong_reference' + | 'open_bot_question' + | 'weak_session' + | 'none' + strongReference: boolean + weakSessionActive: boolean + hasOpenBotQuestion: boolean + lastBotQuestion: string | null + recentBotReply: string | null +} + +export interface ConversationContext { + topicRole: TopicMessageRole + activeWorkflow: TopicWorkflowState + explicitMention: boolean + replyToBot: boolean + directBotAddress: boolean + rollingChatMessages: readonly ConversationHistoryMessage[] + recentThreadMessages: readonly ConversationHistoryMessage[] + recentSessionMessages: readonly ConversationHistoryMessage[] + recentTurns: readonly { + role: 'user' | 'assistant' + text: string + }[] + shouldLoadExpandedContext: boolean + engagement: EngagementAssessment +} + +function toConversationHistoryMessage( + record: TopicMessageHistoryRecord +): ConversationHistoryMessage { + return { + role: record.isBot ? 'assistant' : 'user', + speaker: record.senderDisplayName ?? (record.isBot ? 'Kojori Bot' : 'Unknown'), + text: record.rawText.trim(), + threadId: record.telegramThreadId, + senderTelegramUserId: record.senderTelegramUserId, + isBot: record.isBot, + messageSentAt: record.messageSentAt + } +} + +function compareConversationHistoryMessages( + left: ConversationHistoryMessage, + right: ConversationHistoryMessage +): number { + const leftSentAt = left.messageSentAt?.epochMilliseconds ?? Number.MIN_SAFE_INTEGER + const rightSentAt = right.messageSentAt?.epochMilliseconds ?? Number.MIN_SAFE_INTEGER + + if (leftSentAt !== rightSentAt) { + return leftSentAt - rightSentAt + } + + if (left.isBot !== right.isBot) { + return left.isBot ? 1 : -1 + } + + return 0 +} + +export function rollingWindowStart( + windowMs = ROLLING_CONTEXT_WINDOW_MS, + referenceInstant = nowInstant() +): Instant { + return Temporal.Instant.fromEpochMilliseconds(referenceInstant.epochMilliseconds - windowMs) +} + +function lastBotMessageForUser( + messages: readonly ConversationHistoryMessage[], + telegramUserId: string, + predicate: (message: ConversationHistoryMessage) => boolean +): ConversationHistoryMessage | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message?.isBot || !predicate(message)) { + continue + } + + for (let previousIndex = index - 1; previousIndex >= 0; previousIndex -= 1) { + const previousMessage = messages[previousIndex] + if (!previousMessage || previousMessage.isBot) { + continue + } + + return previousMessage.senderTelegramUserId === telegramUserId ? message : null + } + + return null + } + + return null +} + +function isQuestionLike(text: string): boolean { + return ( + text.includes('?') || + /(?:^|[^\p{L}])(что|какой|какая|какие|когда|why|what|which|who|where|how)(?=$|[^\p{L}])/iu.test( + text + ) + ) +} + +function assessEngagement(input: { + explicitMention: boolean + replyToBot: boolean + activeWorkflow: TopicWorkflowState + directBotAddress: boolean + messageText: string + telegramUserId: string + recentThreadMessages: readonly ConversationHistoryMessage[] + recentSessionMessages: readonly ConversationHistoryMessage[] + referenceInstant?: Instant + weakSessionTtlMs?: number +}): EngagementAssessment { + if (input.explicitMention || input.directBotAddress) { + return { + engaged: true, + reason: 'explicit_mention', + strongReference: false, + weakSessionActive: false, + hasOpenBotQuestion: false, + lastBotQuestion: null, + recentBotReply: null + } + } + + if (input.replyToBot) { + return { + engaged: true, + reason: 'reply_to_bot', + strongReference: false, + weakSessionActive: false, + hasOpenBotQuestion: false, + lastBotQuestion: null, + recentBotReply: null + } + } + + if (input.activeWorkflow !== null) { + return { + engaged: true, + reason: 'active_workflow', + strongReference: false, + weakSessionActive: false, + hasOpenBotQuestion: true, + lastBotQuestion: null, + recentBotReply: null + } + } + + const normalized = input.messageText.trim() + const strongReference = STRONG_CONTEXT_REFERENCE_PATTERN.test(normalized) + const contextMessages = + input.recentThreadMessages.length > 0 ? input.recentThreadMessages : input.recentSessionMessages + const lastBotReply = lastBotMessageForUser(contextMessages, input.telegramUserId, () => true) + const lastBotQuestion = lastBotMessageForUser(contextMessages, input.telegramUserId, (message) => + isQuestionLike(message.text) + ) + const referenceInstant = input.referenceInstant ?? nowInstant() + const weakSessionTtlMs = input.weakSessionTtlMs ?? WEAK_SESSION_TTL_MS + const weakSessionActive = + lastBotReply?.messageSentAt !== null && + lastBotReply?.messageSentAt !== undefined && + referenceInstant.epochMilliseconds - lastBotReply.messageSentAt.epochMilliseconds <= + weakSessionTtlMs + + if (strongReference && (lastBotReply || lastBotQuestion)) { + return { + engaged: true, + reason: 'strong_reference', + strongReference, + weakSessionActive, + hasOpenBotQuestion: Boolean(lastBotQuestion), + lastBotQuestion: lastBotQuestion?.text ?? null, + recentBotReply: lastBotReply?.text ?? null + } + } + + if (lastBotQuestion) { + return { + engaged: false, + reason: 'open_bot_question', + strongReference, + weakSessionActive, + hasOpenBotQuestion: true, + lastBotQuestion: lastBotQuestion.text, + recentBotReply: lastBotReply?.text ?? null + } + } + + if (weakSessionActive) { + return { + engaged: true, + reason: 'weak_session', + strongReference, + weakSessionActive, + hasOpenBotQuestion: false, + lastBotQuestion: null, + recentBotReply: lastBotReply?.text ?? null + } + } + + return { + engaged: false, + reason: 'none', + strongReference, + weakSessionActive: false, + hasOpenBotQuestion: false, + lastBotQuestion: null, + recentBotReply: null + } +} + +function shouldLoadExpandedContext(text: string, strongReference: boolean): boolean { + return strongReference || SUMMARY_REQUEST_PATTERN.test(text.trim()) +} + +export async function buildConversationContext(input: { + repository: TopicMessageHistoryRepository | undefined + householdId: string + telegramChatId: string + telegramThreadId: string | null + telegramUserId: string + topicRole: TopicMessageRole + activeWorkflow: TopicWorkflowState + messageText: string + explicitMention: boolean + replyToBot: boolean + directBotAddress: boolean + memoryStore: AssistantConversationMemoryStore + referenceInstant?: Instant + weakSessionTtlMs?: number +}): Promise { + const rollingChatMessages = input.repository + ? ( + await input.repository.listRecentChatMessages({ + householdId: input.householdId, + telegramChatId: input.telegramChatId, + sentAtOrAfter: rollingWindowStart(ROLLING_CONTEXT_WINDOW_MS, input.referenceInstant), + limit: 80 + }) + ) + .map(toConversationHistoryMessage) + .sort(compareConversationHistoryMessages) + : [] + + const recentThreadMessages = input.telegramThreadId + ? rollingChatMessages + .filter((message) => message.threadId === input.telegramThreadId) + .slice(-20) + : rollingChatMessages.filter((message) => message.threadId === null).slice(-20) + + const recentSessionMessages = rollingChatMessages + .filter( + (message) => + message.senderTelegramUserId === input.telegramUserId || + message.isBot || + message.threadId === input.telegramThreadId + ) + .slice(-20) + + const engagementInput: Parameters[0] = { + explicitMention: input.explicitMention, + replyToBot: input.replyToBot, + activeWorkflow: input.activeWorkflow, + directBotAddress: input.directBotAddress, + messageText: input.messageText, + telegramUserId: input.telegramUserId, + recentThreadMessages, + recentSessionMessages + } + + if (input.referenceInstant) { + engagementInput.referenceInstant = input.referenceInstant + } + + if (input.weakSessionTtlMs !== undefined) { + engagementInput.weakSessionTtlMs = input.weakSessionTtlMs + } + + const engagement = assessEngagement(engagementInput) + + return { + topicRole: input.topicRole, + activeWorkflow: input.activeWorkflow, + explicitMention: input.explicitMention, + replyToBot: input.replyToBot, + directBotAddress: input.directBotAddress, + rollingChatMessages, + recentThreadMessages, + recentSessionMessages, + recentTurns: input.memoryStore.get( + conversationMemoryKey({ + telegramUserId: input.telegramUserId, + telegramChatId: input.telegramChatId, + isPrivateChat: false + }) + ).turns, + shouldLoadExpandedContext: shouldLoadExpandedContext( + input.messageText, + engagement.strongReference + ), + engagement + } +} diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 65bd55b..032262c 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -68,10 +68,13 @@ function topicMessageUpdate( text: string, options?: { replyToBot?: boolean + fromId?: number + firstName?: string + updateId?: number } ) { return { - update_id: 3001, + update_id: options?.updateId ?? 3001, message: { message_id: 88, date: Math.floor(Date.now() / 1000), @@ -82,9 +85,9 @@ function topicMessageUpdate( type: 'supergroup' }, from: { - id: 123456, + id: options?.fromId ?? 123456, is_bot: false, - first_name: 'Stan', + first_name: options?.firstName ?? 'Stan', language_code: 'en' }, text, @@ -1597,9 +1600,14 @@ Confirm or cancel below.`, registerDmAssistant({ bot, assistant: { - async respond() { + async respond(input) { + expect(input.authoritativeFacts).toEqual([ + 'The purchase has not been saved yet.', + 'Detected shared purchase: door handle - 30.00 GEL.', + 'Buttons shown to the user are Confirm and Cancel.' + ]) return { - text: 'fallback', + text: 'Looks like a shared purchase: door handle - 30.00 GEL.', usage: { inputTokens: 10, outputTokens: 2, @@ -1631,7 +1639,7 @@ Confirm or cancel below.`, payload: { chat_id: -100123, message_thread_id: 777, - text: expect.stringContaining('door handle - 30.00 GEL'), + text: 'Looks like a shared purchase: door handle - 30.00 GEL.', reply_markup: { inline_keyboard: [ [ @@ -1814,6 +1822,225 @@ Confirm or cancel below.`, }) }) + test('uses rolling chat history for summary questions instead of finance helper replies', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + const topicMessageHistoryRepository = createTopicMessageHistoryRepository() + let sameDayTexts: string[] = [] + let assistantCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond(input) { + assistantCalls += 1 + sameDayTexts = input.sameDayChatMessages?.map((message) => message.text) ?? [] + + return { + text: 'В чате ты говорил, что думаешь о семечках.', + usage: { + inputTokens: 24, + outputTokens: 10, + totalTokens: 34 + } + } + } + }, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker(), + topicMessageHistoryRepository + }) + + await bot.handleUpdate(topicMessageUpdate('Я думаю о семечках') as never) + await bot.handleUpdate( + topicMessageUpdate('Бот, можешь дать сводку, что происходило в чате?') as never + ) + + expect(assistantCalls).toBe(1) + expect(sameDayTexts).toContain('Я думаю о семечках') + expect(calls.at(-1)).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'В чате ты говорил, что думаешь о семечках.' + } + }) + }) + + test('responds to strong contextual follow-ups without a repeated mention', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let assistantCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond(input) { + assistantCalls += 1 + + return { + text: + assistantCalls === 1 + ? 'Still standing.' + : `Отвечаю по контексту: ${input.userMessage}`, + usage: { + inputTokens: 15, + outputTokens: 8, + totalTokens: 23 + } + } + } + }, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker(), + topicMessageHistoryRepository: createTopicMessageHistoryRepository() + }) + + await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never) + await bot.handleUpdate( + topicMessageUpdate('Вопрос выше, я уже задал, ты просто не ответил') as never + ) + + expect(assistantCalls).toBe(2) + expect(calls.at(-1)).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'Отвечаю по контексту: Вопрос выше, я уже задал, ты просто не ответил' + } + }) + }) + + test('stays silent for casual follow-ups after a recent bot reply', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let assistantCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond() { + assistantCalls += 1 + + return { + text: 'Still standing.', + usage: { + inputTokens: 15, + outputTokens: 8, + totalTokens: 23 + } + } + } + }, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker(), + topicMessageHistoryRepository: createTopicMessageHistoryRepository() + }) + + await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never) + await bot.handleUpdate(topicMessageUpdate('ok', { updateId: 3002 }) as never) + + expect(assistantCalls).toBe(1) + expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1) + }) + test('ignores duplicate deliveries of the same DM update', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 4e4b396..4505bc2 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -10,6 +10,7 @@ import type { import type { Bot, Context } from 'grammy' import { resolveReplyLocale } from './bot-locale' +import { composeAssistantReplyText } from './assistant-composer' import { getBotTranslations, type BotLocale } from './i18n' import type { AssistantConversationMemoryStore, @@ -32,6 +33,10 @@ import type { PurchaseProposalActionResult, PurchaseTopicRecord } from './purchase-topic-ingestion' +import { + buildConversationContext, + type ConversationHistoryMessage +} from './conversation-orchestrator' import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router' import { fallbackTopicMessageRoute, @@ -39,10 +44,7 @@ import { looksLikeDirectBotAddress } from './topic-message-router' import { - historyRecordToTurn, persistTopicHistoryMessage, - shouldLoadExpandedChatHistory, - startOfCurrentDayInTimezone, telegramMessageIdFromMessage, telegramMessageSentAtFromMessage } from './topic-history' @@ -342,45 +344,18 @@ function currentMessageSentAt(ctx: Context) { return typeof ctx.msg?.date === 'number' ? instantFromEpochSeconds(ctx.msg.date) : null } -async function listRecentThreadMessages(input: { - repository: TopicMessageHistoryRepository | undefined - householdId: string - telegramChatId: string - telegramThreadId: string | null -}) { - if (!input.repository || !input.telegramThreadId) { - return [] - } - - const messages = await input.repository.listRecentThreadMessages({ - householdId: input.householdId, - telegramChatId: input.telegramChatId, - telegramThreadId: input.telegramThreadId, - limit: 8 - }) - - return messages.map(historyRecordToTurn) -} - -async function listExpandedChatMessages(input: { - repository: TopicMessageHistoryRepository | undefined - householdId: string - telegramChatId: string - timezone: string - shouldLoad: boolean -}) { - if (!input.repository || !input.shouldLoad) { - return [] - } - - const messages = await input.repository.listRecentChatMessages({ - householdId: input.householdId, - telegramChatId: input.telegramChatId, - sentAtOrAfter: startOfCurrentDayInTimezone(input.timezone), - limit: 40 - }) - - return messages.map(historyRecordToTurn) +function toAssistantMessages(messages: readonly ConversationHistoryMessage[]): readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null +}[] { + return messages.map((message) => ({ + role: message.role, + speaker: message.speaker, + text: message.text, + threadId: message.threadId + })) } async function persistIncomingTopicMessage(input: { @@ -445,6 +420,13 @@ async function routeGroupAssistantMessage(input: { messageText: string isExplicitMention: boolean isReplyToBot: boolean + engagementAssessment: { + engaged: boolean + reason: string + strongReference: boolean + weakSessionActive: boolean + hasOpenBotQuestion: boolean + } assistantContext: string | null assistantTone: string | null memoryStore: AssistantConversationMemoryStore @@ -455,6 +437,12 @@ async function routeGroupAssistantMessage(input: { text: string threadId: string | null }[] + recentChatMessages: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] }) { if (!input.router) { return fallbackTopicMessageRoute({ @@ -464,10 +452,12 @@ async function routeGroupAssistantMessage(input: { isExplicitMention: input.isExplicitMention, isReplyToBot: input.isReplyToBot, activeWorkflow: null, + engagementAssessment: input.engagementAssessment, assistantContext: input.assistantContext, assistantTone: input.assistantTone, recentTurns: input.memoryStore.get(input.memoryKey).turns, - recentThreadMessages: input.recentThreadMessages + recentThreadMessages: input.recentThreadMessages, + recentChatMessages: input.recentChatMessages }) } @@ -478,10 +468,12 @@ async function routeGroupAssistantMessage(input: { isExplicitMention: input.isExplicitMention, isReplyToBot: input.isReplyToBot, activeWorkflow: null, + engagementAssessment: input.engagementAssessment, assistantContext: input.assistantContext, assistantTone: input.assistantTone, recentTurns: input.memoryStore.get(input.memoryKey).turns, - recentThreadMessages: input.recentThreadMessages + recentThreadMessages: input.recentThreadMessages, + recentChatMessages: input.recentChatMessages }) } @@ -1320,11 +1312,10 @@ export function registerDmAssistant(options: { const mention = stripExplicitBotMention(ctx) const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text) - const isAddressed = Boolean( - (mention && mention.strippedText.length > 0) || - directAddressByText || - isReplyToBotMessage(ctx) + const isExplicitMention = Boolean( + (mention && mention.strippedText.length > 0) || directAddressByText ) + const isReplyToBot = isReplyToBotMessage(ctx) const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() @@ -1351,11 +1342,6 @@ export function registerDmAssistant(options: { }) : null - if (binding && !isAddressed) { - await next() - return - } - const member = await options.householdConfigurationRepository.getHouseholdMember( household.householdId, telegramUserId @@ -1420,11 +1406,19 @@ export function registerDmAssistant(options: { topicRole === 'purchase' || topicRole === 'payments' ? getCachedTopicMessageRoute(ctx, topicRole) : null - const recentThreadMessages = await listRecentThreadMessages({ + const conversationContext = await buildConversationContext({ repository: options.topicMessageHistoryRepository, householdId: household.householdId, telegramChatId, - telegramThreadId + telegramThreadId, + telegramUserId, + topicRole, + activeWorkflow: null, + messageText, + explicitMention: isExplicitMention, + replyToBot: isReplyToBot, + directBotAddress: directAddressByText, + memoryStore: options.memoryStore }) const route = cachedRoute ?? @@ -1434,13 +1428,19 @@ export function registerDmAssistant(options: { locale, topicRole, messageText, - isExplicitMention: Boolean(mention) || directAddressByText, - isReplyToBot: isReplyToBotMessage(ctx), + isExplicitMention, + isReplyToBot, + engagementAssessment: conversationContext.engagement, assistantContext: assistantConfig.assistantContext, assistantTone: assistantConfig.assistantTone, memoryStore: options.memoryStore, memoryKey, - recentThreadMessages + recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages), + recentChatMessages: toAssistantMessages( + conversationContext.shouldLoadExpandedContext + ? conversationContext.rollingChatMessages.slice(-40) + : conversationContext.recentSessionMessages + ) }) : null) @@ -1477,6 +1477,16 @@ export function registerDmAssistant(options: { const settings = await options.householdConfigurationRepository.getHouseholdBillingSettings( household.householdId ) + let householdContextPromise: Promise | null = null + const householdContext = () => + (householdContextPromise ??= buildHouseholdContext({ + householdId: household.householdId, + memberId: member.id, + memberDisplayName: member.displayName, + locale, + householdConfigurationRepository: options.householdConfigurationRepository, + financeService + })) if (!binding && options.purchaseRepository && options.purchaseInterpreter) { const purchaseRecord = createGroupPurchaseRecord(ctx, household.householdId, messageText) @@ -1496,11 +1506,33 @@ export function registerDmAssistant(options: { ) if (purchaseResult.status === 'pending_confirmation') { - const purchaseText = getBotTranslations(locale).purchase.proposal( + const fallbackText = getBotTranslations(locale).purchase.proposal( formatPurchaseSummary(locale, purchaseResult), null, null ) + const purchaseText = await composeAssistantReplyText({ + assistant: options.assistant, + locale, + topicRole: 'purchase', + householdContext: await householdContext(), + userMessage: messageText, + recentTurns: options.memoryStore.get(memoryKey).turns, + recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages), + recentChatMessages: toAssistantMessages( + conversationContext.rollingChatMessages.slice(-40) + ), + authoritativeFacts: [ + 'The purchase has not been saved yet.', + `Detected shared purchase: ${formatPurchaseSummary(locale, purchaseResult)}.`, + 'Buttons shown to the user are Confirm and Cancel.' + ], + responseInstructions: + 'Write a short natural purchase confirmation proposal. Mention that the buttons below handle the action, but do not invent any other state changes.', + fallbackText, + logger: options.logger, + logEvent: 'assistant.compose_purchase_reply_failed' + }) await replyAndPersistTopicMessage({ ctx, @@ -1517,20 +1549,51 @@ export function registerDmAssistant(options: { } if (purchaseResult.status === 'clarification_needed') { + const fallbackText = buildPurchaseClarificationText(locale, purchaseResult) + const clarificationText = await composeAssistantReplyText({ + assistant: options.assistant, + locale, + topicRole: 'purchase', + householdContext: await householdContext(), + userMessage: messageText, + recentTurns: options.memoryStore.get(memoryKey).turns, + recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages), + recentChatMessages: toAssistantMessages( + conversationContext.rollingChatMessages.slice(-40) + ), + authoritativeFacts: [ + 'The purchase has not been saved yet.', + purchaseResult.clarificationQuestion + ? `The authoritative clarification question is: ${purchaseResult.clarificationQuestion}` + : 'More details are required before saving the purchase.' + ], + responseInstructions: + 'Write a short natural clarification reply for the purchase flow. Keep it conversational and do not invent saved state.', + fallbackText, + logger: options.logger, + logEvent: 'assistant.compose_purchase_clarification_failed' + }) await replyAndPersistTopicMessage({ ctx, repository: options.topicMessageHistoryRepository, householdId: household.householdId, telegramChatId, telegramThreadId, - text: buildPurchaseClarificationText(locale, purchaseResult) + text: clarificationText }) return } } } - if (!isAddressed || messageText.length === 0) { + const shouldRespond = + messageText.length > 0 && + (isExplicitMention || + isReplyToBot || + conversationContext.engagement.reason === 'strong_reference' || + Boolean(route && route.route !== 'silent')) + + if (!shouldRespond) { await next() return } @@ -1558,14 +1621,37 @@ export function registerDmAssistant(options: { householdConfigurationRepository: options.householdConfigurationRepository }) - if (paymentBalanceReply) { + const prefersConversationHistory = + conversationContext.shouldLoadExpandedContext || + conversationContext.engagement.strongReference + + if (paymentBalanceReply && !prefersConversationHistory) { + const fallbackText = formatPaymentBalanceReplyText(locale, paymentBalanceReply) + const replyText = await composeAssistantReplyText({ + assistant: options.assistant, + locale, + topicRole, + householdContext: await householdContext(), + userMessage: messageText, + recentTurns: options.memoryStore.get(memoryKey).turns, + recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages), + recentChatMessages: toAssistantMessages( + conversationContext.rollingChatMessages.slice(-40) + ), + authoritativeFacts: fallbackText.split('\n').filter(Boolean), + responseInstructions: + 'Write a short natural finance reply using only these payment guidance facts. Do not add unrelated chat summary or extra finance advice.', + fallbackText, + logger: options.logger, + logEvent: 'assistant.compose_payment_balance_failed' + }) await replyAndPersistTopicMessage({ ctx, repository: options.topicMessageHistoryRepository, householdId: household.householdId, telegramChatId, telegramThreadId, - text: formatPaymentBalanceReplyText(locale, paymentBalanceReply) + text: replyText }) return } @@ -1580,14 +1666,32 @@ export function registerDmAssistant(options: { recentTurns: options.memoryStore.get(memoryKey).turns }) - if (memberInsightReply) { + if (memberInsightReply && !prefersConversationHistory) { + const replyText = await composeAssistantReplyText({ + assistant: options.assistant, + locale, + topicRole, + householdContext: await householdContext(), + userMessage: messageText, + recentTurns: options.memoryStore.get(memoryKey).turns, + recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages), + recentChatMessages: toAssistantMessages( + conversationContext.rollingChatMessages.slice(-40) + ), + authoritativeFacts: [memberInsightReply], + responseInstructions: + 'Rewrite these member finance facts as a short natural answer in the user language. Preserve the facts exactly.', + fallbackText: memberInsightReply, + logger: options.logger, + logEvent: 'assistant.compose_member_insight_failed' + }) options.memoryStore.appendTurn(memoryKey, { role: 'user', text: messageText }) options.memoryStore.appendTurn(memoryKey, { role: 'assistant', - text: memberInsightReply + text: replyText }) await replyAndPersistTopicMessage({ @@ -1596,7 +1700,7 @@ export function registerDmAssistant(options: { householdId: household.householdId, telegramChatId, telegramThreadId, - text: memberInsightReply + text: replyText }) return } @@ -1623,14 +1727,12 @@ export function registerDmAssistant(options: { topicMessageHistoryRepository: options.topicMessageHistoryRepository } : {}), - recentThreadMessages, - sameDayChatMessages: await listExpandedChatMessages({ - repository: options.topicMessageHistoryRepository, - householdId: household.householdId, - telegramChatId, - timezone: settings.timezone, - shouldLoad: shouldLoadExpandedChatHistory(messageText) - }) + recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages), + sameDayChatMessages: toAssistantMessages( + conversationContext.shouldLoadExpandedContext + ? conversationContext.rollingChatMessages.slice(-40) + : conversationContext.recentSessionMessages + ) }) } catch (error) { if (dedupeClaim) { diff --git a/apps/bot/src/openai-chat-assistant.ts b/apps/bot/src/openai-chat-assistant.ts index 65e0fc1..af16762 100644 --- a/apps/bot/src/openai-chat-assistant.ts +++ b/apps/bot/src/openai-chat-assistant.ts @@ -19,6 +19,7 @@ export interface ConversationalAssistant { locale: 'en' | 'ru' topicRole: TopicMessageRole householdContext: string + authoritativeFacts?: readonly string[] memorySummary: string | null recentTurns: readonly { role: 'user' | 'assistant' @@ -36,6 +37,7 @@ export interface ConversationalAssistant { text: string threadId: string | null }[] + responseInstructions?: string | null userMessage: string }): Promise } @@ -87,6 +89,7 @@ const ASSISTANT_SYSTEM_PROMPT = [ 'Be calm, concise, playful when appropriate, and quiet by default.', 'Do not act like a form validator or aggressive parser.', 'Do not invent balances, members, billing periods, or completed actions.', + 'Any authoritative facts provided by the system are true and must be preserved exactly.', 'If the user asks you to mutate household state, do not claim the action is complete unless the system explicitly says it was confirmed and saved.', 'For unsupported writes, explain the limitation briefly and suggest the explicit command or confirmation flow.', 'Prefer concise, practical answers.', @@ -145,6 +148,12 @@ export function createOpenAiChatAssistant( topicCapabilityNotes(input.topicRole), 'Bounded household context:', input.householdContext, + input.authoritativeFacts && input.authoritativeFacts.length > 0 + ? [ + 'Authoritative facts:', + ...input.authoritativeFacts.map((fact) => `- ${fact}`) + ].join('\n') + : null, input.recentThreadMessages && input.recentThreadMessages.length > 0 ? [ 'Recent topic thread messages:', @@ -169,7 +178,10 @@ export function createOpenAiChatAssistant( ...input.recentTurns.map((turn) => `${turn.role}: ${turn.text}`) ].join('\n') : null, - input.memorySummary ? `Conversation summary:\n${input.memorySummary}` : null + input.memorySummary ? `Conversation summary:\n${input.memorySummary}` : null, + input.responseInstructions + ? `Response instructions:\n${input.responseInstructions}` + : null ] .filter(Boolean) .join('\n\n') diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts index 26d1832..a3ccca5 100644 --- a/apps/bot/src/payment-topic-ingestion.ts +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -12,6 +12,7 @@ import type { import { getBotTranslations, type BotLocale } from './i18n' import type { AssistantConversationMemoryStore } from './assistant-state' import { conversationMemoryKey } from './assistant-state' +import { buildConversationContext } from './conversation-orchestrator' import { formatPaymentBalanceReplyText, formatPaymentProposalText, @@ -27,7 +28,6 @@ import { type TopicMessageRouter } from './topic-message-router' import { - historyRecordToTurn, persistTopicHistoryMessage, telegramMessageIdFromMessage, telegramMessageSentAtFromMessage @@ -222,24 +222,6 @@ function appendConversation( }) } -async function listRecentThreadMessages( - repository: TopicMessageHistoryRepository | undefined, - record: PaymentTopicRecord -) { - if (!repository) { - return [] - } - - const messages = await repository.listRecentThreadMessages({ - householdId: record.householdId, - telegramChatId: record.chatId, - telegramThreadId: record.threadId, - limit: 8 - }) - - return messages.map(historyRecordToTurn) -} - async function persistIncomingTopicMessage( repository: TopicMessageHistoryRepository | undefined, record: PaymentTopicRecord @@ -294,19 +276,51 @@ async function routePaymentTopicMessage(input: { } } - const recentThreadMessages = await listRecentThreadMessages(input.historyRepository, input.record) + const conversationContext = await buildConversationContext({ + repository: input.historyRepository, + householdId: input.record.householdId, + telegramChatId: input.record.chatId, + telegramThreadId: input.record.threadId, + telegramUserId: input.record.senderTelegramUserId, + topicRole: input.topicRole, + activeWorkflow: input.activeWorkflow, + messageText: input.record.rawText, + explicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText), + replyToBot: input.isReplyToBot, + directBotAddress: looksLikeDirectBotAddress(input.record.rawText), + memoryStore: input.memoryStore ?? { + get() { + return { summary: null, turns: [] } + }, + appendTurn() { + return { summary: null, turns: [] } + } + } + }) return input.router({ locale: input.locale, topicRole: input.topicRole, messageText: input.record.rawText, - isExplicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText), - isReplyToBot: input.isReplyToBot, + isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress, + isReplyToBot: conversationContext.replyToBot, activeWorkflow: input.activeWorkflow, + engagementAssessment: conversationContext.engagement, assistantContext: input.assistantContext, assistantTone: input.assistantTone, recentTurns: input.memoryStore?.get(memoryKeyForRecord(input.record)).turns ?? [], - recentThreadMessages + recentThreadMessages: conversationContext.recentThreadMessages.map((message) => ({ + role: message.role, + speaker: message.speaker, + text: message.text, + threadId: message.threadId + })), + recentChatMessages: conversationContext.recentSessionMessages.map((message) => ({ + role: message.role, + speaker: message.speaker, + text: message.text, + threadId: message.threadId + })) }) } @@ -667,6 +681,15 @@ export function registerConfiguredPaymentTopicIngestion( } if (route.route === 'topic_helper') { + if ( + route.reason === 'context_reference' || + route.reason === 'engaged_context' || + route.reason === 'addressed' + ) { + await next() + return + } + const financeService = financeServiceForHousehold(record.householdId) const member = await financeService.getMemberByTelegramUserId(record.senderTelegramUserId) if (!member) { diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 08eeb51..0c8ebb3 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -17,6 +17,7 @@ import type { import { createDbClient, schema } from '@household/db' import { getBotTranslations, type BotLocale } from './i18n' import type { AssistantConversationMemoryStore } from './assistant-state' +import { buildConversationContext } from './conversation-orchestrator' import type { PurchaseInterpretationAmountSource, PurchaseInterpretation, @@ -30,7 +31,6 @@ import { type TopicMessageRoutingResult } from './topic-message-router' import { - historyRecordToTurn, persistTopicHistoryMessage, telegramMessageIdFromMessage, telegramMessageSentAtFromMessage @@ -1525,24 +1525,6 @@ function rememberAssistantTurn( }) } -async function listRecentThreadMessages( - repository: TopicMessageHistoryRepository | undefined, - record: PurchaseTopicRecord -) { - if (!repository) { - return [] - } - - const messages = await repository.listRecentThreadMessages({ - householdId: record.householdId, - telegramChatId: record.chatId, - telegramThreadId: record.threadId, - limit: 8 - }) - - return messages.map(historyRecordToTurn) -} - async function persistIncomingTopicMessage( repository: TopicMessageHistoryRepository | undefined, record: PurchaseTopicRecord @@ -1629,24 +1611,56 @@ async function routePurchaseTopicMessage(input: { } const key = memoryKeyForRecord(input.record) - const recentTurns = input.memoryStore?.get(key).turns ?? [] - const recentThreadMessages = await listRecentThreadMessages(input.historyRepository, input.record) + const activeWorkflow = (await input.repository.hasClarificationContext(input.record)) + ? 'purchase_clarification' + : null + const conversationContext = await buildConversationContext({ + repository: input.historyRepository, + householdId: input.record.householdId, + telegramChatId: input.record.chatId, + telegramThreadId: input.record.threadId, + telegramUserId: input.record.senderTelegramUserId, + topicRole: 'purchase', + activeWorkflow, + messageText: input.record.rawText, + explicitMention: + stripExplicitBotMention(input.ctx) !== null || + looksLikeDirectBotAddress(input.record.rawText), + replyToBot: isReplyToCurrentBot(input.ctx), + directBotAddress: looksLikeDirectBotAddress(input.record.rawText), + memoryStore: input.memoryStore ?? { + get() { + return { summary: null, turns: [] } + }, + appendTurn() { + return { summary: null, turns: [] } + } + } + }) return input.router({ locale: input.locale, topicRole: 'purchase', messageText: input.record.rawText, - isExplicitMention: - stripExplicitBotMention(input.ctx) !== null || - looksLikeDirectBotAddress(input.record.rawText), - isReplyToBot: isReplyToCurrentBot(input.ctx), - activeWorkflow: (await input.repository.hasClarificationContext(input.record)) - ? 'purchase_clarification' - : null, + isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress, + isReplyToBot: conversationContext.replyToBot, + activeWorkflow, + engagementAssessment: conversationContext.engagement, assistantContext: input.assistantContext ?? null, assistantTone: input.assistantTone ?? null, - recentTurns, - recentThreadMessages + recentTurns: input.memoryStore?.get(key).turns ?? [], + recentThreadMessages: conversationContext.recentThreadMessages.map((message) => ({ + role: message.role, + speaker: message.speaker, + text: message.text, + threadId: message.threadId + })), + recentChatMessages: conversationContext.recentSessionMessages.map((message) => ({ + role: message.role, + speaker: message.speaker, + text: message.text, + threadId: message.threadId + })) }) } diff --git a/apps/bot/src/topic-message-router.ts b/apps/bot/src/topic-message-router.ts index de1866b..7984912 100644 --- a/apps/bot/src/topic-message-router.ts +++ b/apps/bot/src/topic-message-router.ts @@ -25,6 +25,13 @@ export interface TopicMessageRoutingInput { isExplicitMention: boolean isReplyToBot: boolean activeWorkflow: TopicWorkflowState + engagementAssessment?: { + engaged: boolean + reason: string + strongReference: boolean + weakSessionActive: boolean + hasOpenBotQuestion: boolean + } assistantContext?: string | null assistantTone?: string | null recentTurns?: readonly { @@ -37,6 +44,12 @@ export interface TopicMessageRoutingInput { text: string threadId: string | null }[] + recentChatMessages?: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] } export interface TopicMessageRoutingResult { @@ -207,7 +220,8 @@ export function fallbackTopicMessageRoute( input: TopicMessageRoutingInput ): TopicMessageRoutingResult { const normalized = input.messageText.trim() - const isAddressed = input.isExplicitMention || input.isReplyToBot + const isAddressed = + input.isExplicitMention || input.isReplyToBot || input.engagementAssessment?.engaged === true if (normalized.length === 0 || !LETTER_PATTERN.test(normalized)) { return { @@ -311,6 +325,21 @@ export function fallbackTopicMessageRoute( } } + if ( + input.engagementAssessment?.strongReference || + input.engagementAssessment?.weakSessionActive + ) { + return { + route: 'topic_helper', + replyText: null, + helperKind: 'assistant', + shouldStartTyping: true, + shouldClearWorkflow: false, + confidence: 62, + reason: 'engaged_context' + } + } + if (isAddressed) { return { route: 'topic_helper', @@ -356,6 +385,21 @@ function buildRecentThreadMessages(input: TopicMessageRoutingInput): string | nu : null } +function buildRecentChatMessages(input: TopicMessageRoutingInput): string | null { + const recentMessages = input.recentChatMessages + ?.slice(-12) + .map((message) => + message.threadId + ? `[thread ${message.threadId}] ${message.speaker} (${message.role}): ${message.text.trim()}` + : `${message.speaker} (${message.role}): ${message.text.trim()}` + ) + .filter((line) => line.length > 0) + + return recentMessages && recentMessages.length > 0 + ? ['Recent related chat messages:', ...recentMessages].join('\n') + : null +} + export function cacheTopicMessageRoute( ctx: Context, topicRole: CachedTopicMessageRole, @@ -437,7 +481,11 @@ export function createOpenAiTopicMessageRouter( `Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`, `Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`, `Active workflow: ${input.activeWorkflow ?? 'none'}`, + input.engagementAssessment + ? `Engagement assessment: engaged=${input.engagementAssessment.engaged ? 'yes' : 'no'}; reason=${input.engagementAssessment.reason}; strong_reference=${input.engagementAssessment.strongReference ? 'yes' : 'no'}; weak_session=${input.engagementAssessment.weakSessionActive ? 'yes' : 'no'}; open_bot_question=${input.engagementAssessment.hasOpenBotQuestion ? 'yes' : 'no'}` + : null, buildRecentThreadMessages(input), + buildRecentChatMessages(input), buildRecentTurns(input), `Latest message:\n${input.messageText}` ]