mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:34: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,
|
||||
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 }> = []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user