Refine topic assistant conversation context

This commit is contained in:
2026-03-12 22:00:31 +04:00
parent 401bbbdcca
commit 88b50d2cb7
9 changed files with 1226 additions and 135 deletions

View File

@@ -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<string> {
if (!input.assistant) {
return input.fallbackText
}
const logger = input.logger
try {
const responseInput: Parameters<ConversationalAssistant['respond']>[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
}
}

View File

@@ -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> = {}
): 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<typeof instantFromIso>
}) {
const contextInput: Parameters<typeof buildConversationContext>[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)
})
})

View File

@@ -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<ConversationContext> {
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<typeof assessEngagement>[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
}
}

View File

@@ -68,10 +68,13 @@ function topicMessageUpdate(
text: string, text: string,
options?: { options?: {
replyToBot?: boolean replyToBot?: boolean
fromId?: number
firstName?: string
updateId?: number
} }
) { ) {
return { return {
update_id: 3001, update_id: options?.updateId ?? 3001,
message: { message: {
message_id: 88, message_id: 88,
date: Math.floor(Date.now() / 1000), date: Math.floor(Date.now() / 1000),
@@ -82,9 +85,9 @@ function topicMessageUpdate(
type: 'supergroup' type: 'supergroup'
}, },
from: { from: {
id: 123456, id: options?.fromId ?? 123456,
is_bot: false, is_bot: false,
first_name: 'Stan', first_name: options?.firstName ?? 'Stan',
language_code: 'en' language_code: 'en'
}, },
text, text,
@@ -1597,9 +1600,14 @@ Confirm or cancel below.`,
registerDmAssistant({ registerDmAssistant({
bot, bot,
assistant: { 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 { return {
text: 'fallback', text: 'Looks like a shared purchase: door handle - 30.00 GEL.',
usage: { usage: {
inputTokens: 10, inputTokens: 10,
outputTokens: 2, outputTokens: 2,
@@ -1631,7 +1639,7 @@ Confirm or cancel below.`,
payload: { payload: {
chat_id: -100123, chat_id: -100123,
message_thread_id: 777, 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: { reply_markup: {
inline_keyboard: [ 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 () => { test('ignores duplicate deliveries of the same DM update', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -10,6 +10,7 @@ import type {
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
import { resolveReplyLocale } from './bot-locale' import { resolveReplyLocale } from './bot-locale'
import { composeAssistantReplyText } from './assistant-composer'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import type { import type {
AssistantConversationMemoryStore, AssistantConversationMemoryStore,
@@ -32,6 +33,10 @@ import type {
PurchaseProposalActionResult, PurchaseProposalActionResult,
PurchaseTopicRecord PurchaseTopicRecord
} from './purchase-topic-ingestion' } from './purchase-topic-ingestion'
import {
buildConversationContext,
type ConversationHistoryMessage
} from './conversation-orchestrator'
import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router' import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router'
import { import {
fallbackTopicMessageRoute, fallbackTopicMessageRoute,
@@ -39,10 +44,7 @@ import {
looksLikeDirectBotAddress looksLikeDirectBotAddress
} from './topic-message-router' } from './topic-message-router'
import { import {
historyRecordToTurn,
persistTopicHistoryMessage, persistTopicHistoryMessage,
shouldLoadExpandedChatHistory,
startOfCurrentDayInTimezone,
telegramMessageIdFromMessage, telegramMessageIdFromMessage,
telegramMessageSentAtFromMessage telegramMessageSentAtFromMessage
} from './topic-history' } from './topic-history'
@@ -342,45 +344,18 @@ function currentMessageSentAt(ctx: Context) {
return typeof ctx.msg?.date === 'number' ? instantFromEpochSeconds(ctx.msg.date) : null return typeof ctx.msg?.date === 'number' ? instantFromEpochSeconds(ctx.msg.date) : null
} }
async function listRecentThreadMessages(input: { function toAssistantMessages(messages: readonly ConversationHistoryMessage[]): readonly {
repository: TopicMessageHistoryRepository | undefined role: 'user' | 'assistant'
householdId: string speaker: string
telegramChatId: string text: string
telegramThreadId: string | null threadId: string | null
}) { }[] {
if (!input.repository || !input.telegramThreadId) { return messages.map((message) => ({
return [] role: message.role,
} speaker: message.speaker,
text: message.text,
const messages = await input.repository.listRecentThreadMessages({ threadId: message.threadId
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)
} }
async function persistIncomingTopicMessage(input: { async function persistIncomingTopicMessage(input: {
@@ -445,6 +420,13 @@ async function routeGroupAssistantMessage(input: {
messageText: string messageText: string
isExplicitMention: boolean isExplicitMention: boolean
isReplyToBot: boolean isReplyToBot: boolean
engagementAssessment: {
engaged: boolean
reason: string
strongReference: boolean
weakSessionActive: boolean
hasOpenBotQuestion: boolean
}
assistantContext: string | null assistantContext: string | null
assistantTone: string | null assistantTone: string | null
memoryStore: AssistantConversationMemoryStore memoryStore: AssistantConversationMemoryStore
@@ -455,6 +437,12 @@ async function routeGroupAssistantMessage(input: {
text: string text: string
threadId: string | null threadId: string | null
}[] }[]
recentChatMessages: readonly {
role: 'user' | 'assistant'
speaker: string
text: string
threadId: string | null
}[]
}) { }) {
if (!input.router) { if (!input.router) {
return fallbackTopicMessageRoute({ return fallbackTopicMessageRoute({
@@ -464,10 +452,12 @@ async function routeGroupAssistantMessage(input: {
isExplicitMention: input.isExplicitMention, isExplicitMention: input.isExplicitMention,
isReplyToBot: input.isReplyToBot, isReplyToBot: input.isReplyToBot,
activeWorkflow: null, activeWorkflow: null,
engagementAssessment: input.engagementAssessment,
assistantContext: input.assistantContext, assistantContext: input.assistantContext,
assistantTone: input.assistantTone, assistantTone: input.assistantTone,
recentTurns: input.memoryStore.get(input.memoryKey).turns, 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, isExplicitMention: input.isExplicitMention,
isReplyToBot: input.isReplyToBot, isReplyToBot: input.isReplyToBot,
activeWorkflow: null, activeWorkflow: null,
engagementAssessment: input.engagementAssessment,
assistantContext: input.assistantContext, assistantContext: input.assistantContext,
assistantTone: input.assistantTone, assistantTone: input.assistantTone,
recentTurns: input.memoryStore.get(input.memoryKey).turns, 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 mention = stripExplicitBotMention(ctx)
const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text) const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text)
const isAddressed = Boolean( const isExplicitMention = Boolean(
(mention && mention.strippedText.length > 0) || (mention && mention.strippedText.length > 0) || directAddressByText
directAddressByText ||
isReplyToBotMessage(ctx)
) )
const isReplyToBot = isReplyToBotMessage(ctx)
const telegramUserId = ctx.from?.id?.toString() const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString() const telegramChatId = ctx.chat?.id?.toString()
@@ -1351,11 +1342,6 @@ export function registerDmAssistant(options: {
}) })
: null : null
if (binding && !isAddressed) {
await next()
return
}
const member = await options.householdConfigurationRepository.getHouseholdMember( const member = await options.householdConfigurationRepository.getHouseholdMember(
household.householdId, household.householdId,
telegramUserId telegramUserId
@@ -1420,11 +1406,19 @@ export function registerDmAssistant(options: {
topicRole === 'purchase' || topicRole === 'payments' topicRole === 'purchase' || topicRole === 'payments'
? getCachedTopicMessageRoute(ctx, topicRole) ? getCachedTopicMessageRoute(ctx, topicRole)
: null : null
const recentThreadMessages = await listRecentThreadMessages({ const conversationContext = await buildConversationContext({
repository: options.topicMessageHistoryRepository, repository: options.topicMessageHistoryRepository,
householdId: household.householdId, householdId: household.householdId,
telegramChatId, telegramChatId,
telegramThreadId telegramThreadId,
telegramUserId,
topicRole,
activeWorkflow: null,
messageText,
explicitMention: isExplicitMention,
replyToBot: isReplyToBot,
directBotAddress: directAddressByText,
memoryStore: options.memoryStore
}) })
const route = const route =
cachedRoute ?? cachedRoute ??
@@ -1434,13 +1428,19 @@ export function registerDmAssistant(options: {
locale, locale,
topicRole, topicRole,
messageText, messageText,
isExplicitMention: Boolean(mention) || directAddressByText, isExplicitMention,
isReplyToBot: isReplyToBotMessage(ctx), isReplyToBot,
engagementAssessment: conversationContext.engagement,
assistantContext: assistantConfig.assistantContext, assistantContext: assistantConfig.assistantContext,
assistantTone: assistantConfig.assistantTone, assistantTone: assistantConfig.assistantTone,
memoryStore: options.memoryStore, memoryStore: options.memoryStore,
memoryKey, memoryKey,
recentThreadMessages recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages),
recentChatMessages: toAssistantMessages(
conversationContext.shouldLoadExpandedContext
? conversationContext.rollingChatMessages.slice(-40)
: conversationContext.recentSessionMessages
)
}) })
: null) : null)
@@ -1477,6 +1477,16 @@ export function registerDmAssistant(options: {
const settings = await options.householdConfigurationRepository.getHouseholdBillingSettings( const settings = await options.householdConfigurationRepository.getHouseholdBillingSettings(
household.householdId household.householdId
) )
let householdContextPromise: Promise<string> | 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) { if (!binding && options.purchaseRepository && options.purchaseInterpreter) {
const purchaseRecord = createGroupPurchaseRecord(ctx, household.householdId, messageText) const purchaseRecord = createGroupPurchaseRecord(ctx, household.householdId, messageText)
@@ -1496,11 +1506,33 @@ export function registerDmAssistant(options: {
) )
if (purchaseResult.status === 'pending_confirmation') { if (purchaseResult.status === 'pending_confirmation') {
const purchaseText = getBotTranslations(locale).purchase.proposal( const fallbackText = getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult), formatPurchaseSummary(locale, purchaseResult),
null, null,
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({ await replyAndPersistTopicMessage({
ctx, ctx,
@@ -1517,20 +1549,51 @@ export function registerDmAssistant(options: {
} }
if (purchaseResult.status === 'clarification_needed') { 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({ await replyAndPersistTopicMessage({
ctx, ctx,
repository: options.topicMessageHistoryRepository, repository: options.topicMessageHistoryRepository,
householdId: household.householdId, householdId: household.householdId,
telegramChatId, telegramChatId,
telegramThreadId, telegramThreadId,
text: buildPurchaseClarificationText(locale, purchaseResult) text: clarificationText
}) })
return 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() await next()
return return
} }
@@ -1558,14 +1621,37 @@ export function registerDmAssistant(options: {
householdConfigurationRepository: options.householdConfigurationRepository 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({ await replyAndPersistTopicMessage({
ctx, ctx,
repository: options.topicMessageHistoryRepository, repository: options.topicMessageHistoryRepository,
householdId: household.householdId, householdId: household.householdId,
telegramChatId, telegramChatId,
telegramThreadId, telegramThreadId,
text: formatPaymentBalanceReplyText(locale, paymentBalanceReply) text: replyText
}) })
return return
} }
@@ -1580,14 +1666,32 @@ export function registerDmAssistant(options: {
recentTurns: options.memoryStore.get(memoryKey).turns 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, { options.memoryStore.appendTurn(memoryKey, {
role: 'user', role: 'user',
text: messageText text: messageText
}) })
options.memoryStore.appendTurn(memoryKey, { options.memoryStore.appendTurn(memoryKey, {
role: 'assistant', role: 'assistant',
text: memberInsightReply text: replyText
}) })
await replyAndPersistTopicMessage({ await replyAndPersistTopicMessage({
@@ -1596,7 +1700,7 @@ export function registerDmAssistant(options: {
householdId: household.householdId, householdId: household.householdId,
telegramChatId, telegramChatId,
telegramThreadId, telegramThreadId,
text: memberInsightReply text: replyText
}) })
return return
} }
@@ -1623,14 +1727,12 @@ export function registerDmAssistant(options: {
topicMessageHistoryRepository: options.topicMessageHistoryRepository topicMessageHistoryRepository: options.topicMessageHistoryRepository
} }
: {}), : {}),
recentThreadMessages, recentThreadMessages: toAssistantMessages(conversationContext.recentThreadMessages),
sameDayChatMessages: await listExpandedChatMessages({ sameDayChatMessages: toAssistantMessages(
repository: options.topicMessageHistoryRepository, conversationContext.shouldLoadExpandedContext
householdId: household.householdId, ? conversationContext.rollingChatMessages.slice(-40)
telegramChatId, : conversationContext.recentSessionMessages
timezone: settings.timezone, )
shouldLoad: shouldLoadExpandedChatHistory(messageText)
})
}) })
} catch (error) { } catch (error) {
if (dedupeClaim) { if (dedupeClaim) {

View File

@@ -19,6 +19,7 @@ export interface ConversationalAssistant {
locale: 'en' | 'ru' locale: 'en' | 'ru'
topicRole: TopicMessageRole topicRole: TopicMessageRole
householdContext: string householdContext: string
authoritativeFacts?: readonly string[]
memorySummary: string | null memorySummary: string | null
recentTurns: readonly { recentTurns: readonly {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -36,6 +37,7 @@ export interface ConversationalAssistant {
text: string text: string
threadId: string | null threadId: string | null
}[] }[]
responseInstructions?: string | null
userMessage: string userMessage: string
}): Promise<AssistantReply> }): Promise<AssistantReply>
} }
@@ -87,6 +89,7 @@ const ASSISTANT_SYSTEM_PROMPT = [
'Be calm, concise, playful when appropriate, and quiet by default.', 'Be calm, concise, playful when appropriate, and quiet by default.',
'Do not act like a form validator or aggressive parser.', 'Do not act like a form validator or aggressive parser.',
'Do not invent balances, members, billing periods, or completed actions.', '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.', '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.', 'For unsupported writes, explain the limitation briefly and suggest the explicit command or confirmation flow.',
'Prefer concise, practical answers.', 'Prefer concise, practical answers.',
@@ -145,6 +148,12 @@ export function createOpenAiChatAssistant(
topicCapabilityNotes(input.topicRole), topicCapabilityNotes(input.topicRole),
'Bounded household context:', 'Bounded household context:',
input.householdContext, input.householdContext,
input.authoritativeFacts && input.authoritativeFacts.length > 0
? [
'Authoritative facts:',
...input.authoritativeFacts.map((fact) => `- ${fact}`)
].join('\n')
: null,
input.recentThreadMessages && input.recentThreadMessages.length > 0 input.recentThreadMessages && input.recentThreadMessages.length > 0
? [ ? [
'Recent topic thread messages:', 'Recent topic thread messages:',
@@ -169,7 +178,10 @@ export function createOpenAiChatAssistant(
...input.recentTurns.map((turn) => `${turn.role}: ${turn.text}`) ...input.recentTurns.map((turn) => `${turn.role}: ${turn.text}`)
].join('\n') ].join('\n')
: null, : 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) .filter(Boolean)
.join('\n\n') .join('\n\n')

View File

@@ -12,6 +12,7 @@ import type {
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 { conversationMemoryKey } from './assistant-state'
import { buildConversationContext } from './conversation-orchestrator'
import { import {
formatPaymentBalanceReplyText, formatPaymentBalanceReplyText,
formatPaymentProposalText, formatPaymentProposalText,
@@ -27,7 +28,6 @@ import {
type TopicMessageRouter type TopicMessageRouter
} from './topic-message-router' } from './topic-message-router'
import { import {
historyRecordToTurn,
persistTopicHistoryMessage, persistTopicHistoryMessage,
telegramMessageIdFromMessage, telegramMessageIdFromMessage,
telegramMessageSentAtFromMessage 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( async function persistIncomingTopicMessage(
repository: TopicMessageHistoryRepository | undefined, repository: TopicMessageHistoryRepository | undefined,
record: PaymentTopicRecord 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({ return input.router({
locale: input.locale, locale: input.locale,
topicRole: input.topicRole, topicRole: input.topicRole,
messageText: input.record.rawText, messageText: input.record.rawText,
isExplicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText), isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress,
isReplyToBot: input.isReplyToBot, isReplyToBot: conversationContext.replyToBot,
activeWorkflow: input.activeWorkflow, activeWorkflow: input.activeWorkflow,
engagementAssessment: conversationContext.engagement,
assistantContext: input.assistantContext, assistantContext: input.assistantContext,
assistantTone: input.assistantTone, assistantTone: input.assistantTone,
recentTurns: input.memoryStore?.get(memoryKeyForRecord(input.record)).turns ?? [], 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.route === 'topic_helper') {
if (
route.reason === 'context_reference' ||
route.reason === 'engaged_context' ||
route.reason === 'addressed'
) {
await next()
return
}
const financeService = financeServiceForHousehold(record.householdId) const financeService = financeServiceForHousehold(record.householdId)
const member = await financeService.getMemberByTelegramUserId(record.senderTelegramUserId) const member = await financeService.getMemberByTelegramUserId(record.senderTelegramUserId)
if (!member) { if (!member) {

View File

@@ -17,6 +17,7 @@ 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 { buildConversationContext } from './conversation-orchestrator'
import type { import type {
PurchaseInterpretationAmountSource, PurchaseInterpretationAmountSource,
PurchaseInterpretation, PurchaseInterpretation,
@@ -30,7 +31,6 @@ import {
type TopicMessageRoutingResult type TopicMessageRoutingResult
} from './topic-message-router' } from './topic-message-router'
import { import {
historyRecordToTurn,
persistTopicHistoryMessage, persistTopicHistoryMessage,
telegramMessageIdFromMessage, telegramMessageIdFromMessage,
telegramMessageSentAtFromMessage 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( async function persistIncomingTopicMessage(
repository: TopicMessageHistoryRepository | undefined, repository: TopicMessageHistoryRepository | undefined,
record: PurchaseTopicRecord record: PurchaseTopicRecord
@@ -1629,24 +1611,56 @@ async function routePurchaseTopicMessage(input: {
} }
const key = memoryKeyForRecord(input.record) const key = memoryKeyForRecord(input.record)
const recentTurns = input.memoryStore?.get(key).turns ?? [] const activeWorkflow = (await input.repository.hasClarificationContext(input.record))
const recentThreadMessages = await listRecentThreadMessages(input.historyRepository, 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({ return input.router({
locale: input.locale, locale: input.locale,
topicRole: 'purchase', topicRole: 'purchase',
messageText: input.record.rawText, messageText: input.record.rawText,
isExplicitMention: isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress,
stripExplicitBotMention(input.ctx) !== null || isReplyToBot: conversationContext.replyToBot,
looksLikeDirectBotAddress(input.record.rawText), activeWorkflow,
isReplyToBot: isReplyToCurrentBot(input.ctx), engagementAssessment: conversationContext.engagement,
activeWorkflow: (await input.repository.hasClarificationContext(input.record))
? 'purchase_clarification'
: null,
assistantContext: input.assistantContext ?? null, assistantContext: input.assistantContext ?? null,
assistantTone: input.assistantTone ?? null, assistantTone: input.assistantTone ?? null,
recentTurns, recentTurns: input.memoryStore?.get(key).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
}))
}) })
} }

View File

@@ -25,6 +25,13 @@ export interface TopicMessageRoutingInput {
isExplicitMention: boolean isExplicitMention: boolean
isReplyToBot: boolean isReplyToBot: boolean
activeWorkflow: TopicWorkflowState activeWorkflow: TopicWorkflowState
engagementAssessment?: {
engaged: boolean
reason: string
strongReference: boolean
weakSessionActive: boolean
hasOpenBotQuestion: boolean
}
assistantContext?: string | null assistantContext?: string | null
assistantTone?: string | null assistantTone?: string | null
recentTurns?: readonly { recentTurns?: readonly {
@@ -37,6 +44,12 @@ export interface TopicMessageRoutingInput {
text: string text: string
threadId: string | null threadId: string | null
}[] }[]
recentChatMessages?: readonly {
role: 'user' | 'assistant'
speaker: string
text: string
threadId: string | null
}[]
} }
export interface TopicMessageRoutingResult { export interface TopicMessageRoutingResult {
@@ -207,7 +220,8 @@ export function fallbackTopicMessageRoute(
input: TopicMessageRoutingInput input: TopicMessageRoutingInput
): TopicMessageRoutingResult { ): TopicMessageRoutingResult {
const normalized = input.messageText.trim() 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)) { if (normalized.length === 0 || !LETTER_PATTERN.test(normalized)) {
return { 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) { if (isAddressed) {
return { return {
route: 'topic_helper', route: 'topic_helper',
@@ -356,6 +385,21 @@ function buildRecentThreadMessages(input: TopicMessageRoutingInput): string | nu
: null : 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( export function cacheTopicMessageRoute(
ctx: Context, ctx: Context,
topicRole: CachedTopicMessageRole, topicRole: CachedTopicMessageRole,
@@ -437,7 +481,11 @@ export function createOpenAiTopicMessageRouter(
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`, `Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
`Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`, `Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`,
`Active workflow: ${input.activeWorkflow ?? 'none'}`, `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), buildRecentThreadMessages(input),
buildRecentChatMessages(input),
buildRecentTurns(input), buildRecentTurns(input),
`Latest message:\n${input.messageText}` `Latest message:\n${input.messageText}`
] ]