mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:44:02 +00:00
Refine topic assistant conversation context
This commit is contained in:
79
apps/bot/src/assistant-composer.ts
Normal file
79
apps/bot/src/assistant-composer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
335
apps/bot/src/conversation-orchestrator.ts
Normal file
335
apps/bot/src/conversation-orchestrator.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }> = []
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user