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,
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 }> = []

View File

@@ -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<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) {
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) {

View File

@@ -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<AssistantReply>
}
@@ -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')

View File

@@ -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) {

View File

@@ -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
}))
})
}

View File

@@ -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}`
]