mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 20:14:02 +00:00
Refine topic assistant conversation context
This commit is contained in:
251
apps/bot/src/conversation-orchestrator.test.ts
Normal file
251
apps/bot/src/conversation-orchestrator.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user