mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
fix(bot): improve purchase-topic conversation flow
This commit is contained in:
@@ -436,6 +436,12 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedAmountMinor: bigint
|
||||
parsedCurrency: 'GEL' | 'USD'
|
||||
parsedItemDescription: string
|
||||
participants: readonly {
|
||||
id: string
|
||||
memberId: string
|
||||
displayName: string
|
||||
included: boolean
|
||||
}[]
|
||||
status: 'pending_confirmation' | 'confirmed' | 'cancelled'
|
||||
}
|
||||
>()
|
||||
@@ -458,6 +464,14 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'door handle',
|
||||
participants: [
|
||||
{
|
||||
id: 'participant-1',
|
||||
memberId: 'member-1',
|
||||
displayName: 'Mia',
|
||||
included: true
|
||||
}
|
||||
],
|
||||
status: 'pending_confirmation'
|
||||
})
|
||||
|
||||
@@ -502,6 +516,14 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedAmountMinor: 4500n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'sausages',
|
||||
participants: [
|
||||
{
|
||||
id: 'participant-1',
|
||||
memberId: 'member-1',
|
||||
displayName: 'Mia',
|
||||
included: true
|
||||
}
|
||||
],
|
||||
status: 'pending_confirmation'
|
||||
})
|
||||
|
||||
@@ -553,7 +575,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: proposal.participants
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +596,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: proposal.participants
|
||||
}
|
||||
},
|
||||
async cancel(purchaseMessageId, actorTelegramUserId) {
|
||||
@@ -600,7 +624,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: proposal.participants
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +645,8 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: proposal.participants
|
||||
}
|
||||
},
|
||||
async toggleParticipant() {
|
||||
|
||||
@@ -32,7 +32,11 @@ import type {
|
||||
PurchaseTopicRecord
|
||||
} from './purchase-topic-ingestion'
|
||||
import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router'
|
||||
import { fallbackTopicMessageRoute, getCachedTopicMessageRoute } from './topic-message-router'
|
||||
import {
|
||||
fallbackTopicMessageRoute,
|
||||
getCachedTopicMessageRoute,
|
||||
looksLikeDirectBotAddress
|
||||
} from './topic-message-router'
|
||||
import { startTypingIndicator } from './telegram-chat-action'
|
||||
import { stripExplicitBotMention } from './telegram-mentions'
|
||||
|
||||
@@ -1129,8 +1133,11 @@ export function registerDmAssistant(options: {
|
||||
}
|
||||
|
||||
const mention = stripExplicitBotMention(ctx)
|
||||
const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text)
|
||||
const isAddressed = Boolean(
|
||||
(mention && mention.strippedText.length > 0) || isReplyToBotMessage(ctx)
|
||||
(mention && mention.strippedText.length > 0) ||
|
||||
directAddressByText ||
|
||||
isReplyToBotMessage(ctx)
|
||||
)
|
||||
|
||||
const telegramUserId = ctx.from?.id?.toString()
|
||||
@@ -1234,7 +1241,7 @@ export function registerDmAssistant(options: {
|
||||
locale,
|
||||
topicRole,
|
||||
messageText,
|
||||
isExplicitMention: Boolean(mention),
|
||||
isExplicitMention: Boolean(mention) || directAddressByText,
|
||||
isReplyToBot: isReplyToBotMessage(ctx),
|
||||
assistantContext: assistantConfig.assistantContext,
|
||||
assistantTone: assistantConfig.assistantTone,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
cacheTopicMessageRoute,
|
||||
getCachedTopicMessageRoute,
|
||||
looksLikeDirectBotAddress,
|
||||
type TopicMessageRouter
|
||||
} from './topic-message-router'
|
||||
import { stripExplicitBotMention } from './telegram-mentions'
|
||||
@@ -252,7 +253,7 @@ async function routePaymentTopicMessage(input: {
|
||||
locale: input.locale,
|
||||
topicRole: input.topicRole,
|
||||
messageText: input.record.rawText,
|
||||
isExplicitMention: input.isExplicitMention,
|
||||
isExplicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText),
|
||||
isReplyToBot: input.isReplyToBot,
|
||||
activeWorkflow: input.activeWorkflow,
|
||||
assistantContext: input.assistantContext,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { instantFromIso } from '@household/domain'
|
||||
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||
import { createTelegramBot } from './bot'
|
||||
import { createInMemoryAssistantConversationMemoryStore } from './assistant-state'
|
||||
|
||||
import {
|
||||
buildPurchaseAcknowledgement,
|
||||
extractPurchaseTopicCandidate,
|
||||
registerConfiguredPurchaseTopicIngestion,
|
||||
registerPurchaseTopicIngestion,
|
||||
resolveConfiguredPurchaseTopicRecord,
|
||||
type PurchaseMessageIngestionRepository,
|
||||
@@ -52,6 +55,7 @@ function purchaseUpdate(
|
||||
text: string,
|
||||
options: {
|
||||
replyToBot?: boolean
|
||||
threadId?: number
|
||||
} = {}
|
||||
) {
|
||||
const commandToken = text.split(' ')[0] ?? text
|
||||
@@ -61,7 +65,7 @@ function purchaseUpdate(
|
||||
message: {
|
||||
message_id: 55,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
message_thread_id: 777,
|
||||
message_thread_id: options.threadId ?? 777,
|
||||
is_topic_message: true,
|
||||
chat: {
|
||||
id: Number(config.householdChatId),
|
||||
@@ -1660,6 +1664,260 @@ Confirm or cancel below.`,
|
||||
})
|
||||
})
|
||||
|
||||
test('uses recent silent planning context for direct bot-address advice replies', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
const memoryStore = createInMemoryAssistantConversationMemoryStore(12)
|
||||
let sawDirectAddress = false
|
||||
let recentTurnTexts: string[] = []
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: true
|
||||
} as never
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async confirm() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
registerPurchaseTopicIngestion(bot, config, repository, {
|
||||
memoryStore,
|
||||
router: async (input) => {
|
||||
if (input.messageText.includes('думаю купить')) {
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 90,
|
||||
reason: 'planning'
|
||||
}
|
||||
}
|
||||
|
||||
sawDirectAddress = input.isExplicitMention
|
||||
recentTurnTexts = input.recentTurns?.map((turn) => turn.text) ?? []
|
||||
|
||||
return {
|
||||
route: 'chat_reply',
|
||||
replyText: 'Если 5 кг стоят 20 лари, это 4 лари за кило. Я бы еще сравнил цену.',
|
||||
helperKind: 'assistant',
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 92,
|
||||
reason: 'planning_advice'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await bot.handleUpdate(
|
||||
purchaseUpdate('В общем, думаю купить 5 килограмм картошки за 20 лари') as never
|
||||
)
|
||||
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?') as never)
|
||||
|
||||
expect(sawDirectAddress).toBe(true)
|
||||
expect(recentTurnTexts).toContain('В общем, думаю купить 5 килограмм картошки за 20 лари')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: 'Если 5 кг стоят 20 лари, это 4 лари за кило. Я бы еще сравнил цену.'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('does not treat ordinary bot nouns as direct address', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: true
|
||||
} as never
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async confirm() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
registerPurchaseTopicIngestion(bot, config, repository, {
|
||||
router: async (input) => ({
|
||||
route: input.isExplicitMention ? 'chat_reply' : 'silent',
|
||||
replyText: input.isExplicitMention ? 'heard you' : null,
|
||||
helperKind: input.isExplicitMention ? 'assistant' : null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 90,
|
||||
reason: 'test'
|
||||
})
|
||||
})
|
||||
|
||||
await bot.handleUpdate(purchaseUpdate('Думаю купить bot vacuum за 200 лари') as never)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('keeps silent planning context scoped to the current purchase thread', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
const memoryStore = createInMemoryAssistantConversationMemoryStore(12)
|
||||
let recentTurnTexts: string[] = []
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: true
|
||||
} as never
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async confirm() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
const householdConfigurationRepository = {
|
||||
findHouseholdTopicByTelegramContext: async ({
|
||||
telegramThreadId
|
||||
}: {
|
||||
telegramThreadId: string
|
||||
}) => ({
|
||||
householdId: config.householdId,
|
||||
telegramThreadId,
|
||||
role: 'purchase' as const,
|
||||
topicName: null
|
||||
}),
|
||||
getHouseholdBillingSettings: async () => ({
|
||||
householdId: config.householdId,
|
||||
paymentBalanceAdjustmentPolicy: 'utilities' as const,
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD' as const,
|
||||
rentDueDay: 4,
|
||||
rentWarningDay: 2,
|
||||
utilitiesDueDay: 12,
|
||||
utilitiesReminderDay: 10,
|
||||
timezone: 'Asia/Tbilisi',
|
||||
settlementCurrency: 'GEL' as const
|
||||
}),
|
||||
getHouseholdChatByHouseholdId: async () => ({
|
||||
householdId: config.householdId,
|
||||
householdName: 'Test household',
|
||||
telegramChatId: config.householdChatId,
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Test household',
|
||||
defaultLocale: 'en' as const
|
||||
}),
|
||||
getHouseholdAssistantConfig: async () => ({
|
||||
householdId: config.householdId,
|
||||
assistantContext: null,
|
||||
assistantTone: null
|
||||
})
|
||||
} satisfies Pick<
|
||||
HouseholdConfigurationRepository,
|
||||
| 'findHouseholdTopicByTelegramContext'
|
||||
| 'getHouseholdBillingSettings'
|
||||
| 'getHouseholdChatByHouseholdId'
|
||||
| 'getHouseholdAssistantConfig'
|
||||
>
|
||||
|
||||
registerConfiguredPurchaseTopicIngestion(
|
||||
bot,
|
||||
householdConfigurationRepository as unknown as HouseholdConfigurationRepository,
|
||||
repository,
|
||||
{
|
||||
memoryStore,
|
||||
router: async (input) => {
|
||||
if (input.messageText.includes('картошки')) {
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 90,
|
||||
reason: 'planning'
|
||||
}
|
||||
}
|
||||
|
||||
recentTurnTexts = input.recentTurns?.map((turn) => turn.text) ?? []
|
||||
|
||||
return {
|
||||
route: 'chat_reply',
|
||||
replyText: 'No leaked context here.',
|
||||
helperKind: 'assistant',
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 91,
|
||||
reason: 'thread_scoped'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await bot.handleUpdate(
|
||||
purchaseUpdate('Думаю купить 5 килограмм картошки за 20 лари', { threadId: 777 }) as never
|
||||
)
|
||||
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?', { threadId: 778 }) as never)
|
||||
|
||||
expect(recentTurnTexts).not.toContain('Думаю купить 5 килограмм картошки за 20 лари')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: 'No leaked context here.'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('confirms a pending proposal and edits the bot message', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
@@ -1698,7 +1956,8 @@ Confirm or cancel below.`,
|
||||
parsedCurrency: 'GEL' as const,
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
@@ -1725,7 +1984,11 @@ Confirm or cancel below.`,
|
||||
payload: {
|
||||
chat_id: Number(config.householdChatId),
|
||||
message_id: 77,
|
||||
text: 'Purchase confirmed: toilet paper - 30.00 GEL',
|
||||
text: `Purchase confirmed: toilet paper - 30.00 GEL
|
||||
|
||||
Participants:
|
||||
- Mia
|
||||
- Dima (excluded)`,
|
||||
reply_markup: {
|
||||
inline_keyboard: []
|
||||
}
|
||||
@@ -1815,7 +2078,8 @@ Confirm or cancel below.`,
|
||||
parsedCurrency: 'GEL' as const,
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
@@ -1839,7 +2103,11 @@ Confirm or cancel below.`,
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'editMessageText',
|
||||
payload: {
|
||||
text: 'Purchase confirmed: toilet paper - 30.00 GEL'
|
||||
text: `Purchase confirmed: toilet paper - 30.00 GEL
|
||||
|
||||
Participants:
|
||||
- Mia
|
||||
- Dima (excluded)`
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1876,7 +2144,8 @@ Confirm or cancel below.`,
|
||||
parsedCurrency: 'GEL' as const,
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
parserMode: 'llm' as const,
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async toggleParticipant() {
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import { getBotTranslations, type BotLocale } from './i18n'
|
||||
import type { AssistantConversationMemoryStore } from './assistant-state'
|
||||
import { conversationMemoryKey } from './assistant-state'
|
||||
import type {
|
||||
PurchaseInterpretationAmountSource,
|
||||
PurchaseInterpretation,
|
||||
@@ -19,6 +18,7 @@ import type {
|
||||
import {
|
||||
cacheTopicMessageRoute,
|
||||
getCachedTopicMessageRoute,
|
||||
looksLikeDirectBotAddress,
|
||||
type TopicMessageRouter,
|
||||
type TopicMessageRoutingResult
|
||||
} from './topic-message-router'
|
||||
@@ -135,6 +135,7 @@ export type PurchaseProposalActionResult =
|
||||
status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled'
|
||||
purchaseMessageId: string
|
||||
householdId: string
|
||||
participants: readonly PurchaseProposalParticipant[]
|
||||
} & PurchaseProposalFields)
|
||||
| {
|
||||
status: 'forbidden'
|
||||
@@ -844,6 +845,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
status: targetStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
|
||||
purchaseMessageId: existing.id,
|
||||
householdId: existing.householdId,
|
||||
participants: toProposalParticipants(await getStoredParticipants(existing.id)),
|
||||
...toProposalFields(existing)
|
||||
}
|
||||
}
|
||||
@@ -899,6 +901,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
reloaded.processingStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
|
||||
purchaseMessageId: reloaded.id,
|
||||
householdId: reloaded.householdId,
|
||||
participants: toProposalParticipants(await getStoredParticipants(reloaded.id)),
|
||||
...toProposalFields(reloaded)
|
||||
}
|
||||
}
|
||||
@@ -914,6 +917,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
status: targetStatus,
|
||||
purchaseMessageId: stored.id,
|
||||
householdId: stored.householdId,
|
||||
participants: toProposalParticipants(await getStoredParticipants(stored.id)),
|
||||
...toProposalFields(stored)
|
||||
}
|
||||
}
|
||||
@@ -1440,29 +1444,33 @@ async function resolveAssistantConfig(
|
||||
}
|
||||
|
||||
function memoryKeyForRecord(record: PurchaseTopicRecord): string {
|
||||
return conversationMemoryKey({
|
||||
telegramUserId: record.senderTelegramUserId,
|
||||
telegramChatId: record.chatId,
|
||||
isPrivateChat: false
|
||||
})
|
||||
return `group:${record.chatId}:${record.senderTelegramUserId}:thread:${record.threadId}`
|
||||
}
|
||||
|
||||
function appendConversation(
|
||||
function rememberUserTurn(
|
||||
memoryStore: AssistantConversationMemoryStore | undefined,
|
||||
record: PurchaseTopicRecord,
|
||||
userText: string,
|
||||
assistantText: string
|
||||
record: PurchaseTopicRecord
|
||||
): void {
|
||||
if (!memoryStore) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = memoryKeyForRecord(record)
|
||||
memoryStore.appendTurn(key, {
|
||||
memoryStore.appendTurn(memoryKeyForRecord(record), {
|
||||
role: 'user',
|
||||
text: userText
|
||||
text: record.rawText
|
||||
})
|
||||
memoryStore.appendTurn(key, {
|
||||
}
|
||||
|
||||
function rememberAssistantTurn(
|
||||
memoryStore: AssistantConversationMemoryStore | undefined,
|
||||
record: PurchaseTopicRecord,
|
||||
assistantText: string | null
|
||||
): void {
|
||||
if (!memoryStore || !assistantText) {
|
||||
return
|
||||
}
|
||||
|
||||
memoryStore.appendTurn(memoryKeyForRecord(record), {
|
||||
role: 'assistant',
|
||||
text: assistantText
|
||||
})
|
||||
@@ -1540,7 +1548,9 @@ async function routePurchaseTopicMessage(input: {
|
||||
locale: input.locale,
|
||||
topicRole: 'purchase',
|
||||
messageText: input.record.rawText,
|
||||
isExplicitMention: stripExplicitBotMention(input.ctx) !== null,
|
||||
isExplicitMention:
|
||||
stripExplicitBotMention(input.ctx) !== null ||
|
||||
looksLikeDirectBotAddress(input.record.rawText),
|
||||
isReplyToBot: isReplyToCurrentBot(input.ctx),
|
||||
activeWorkflow: (await input.repository.hasClarificationContext(input.record))
|
||||
? 'purchase_clarification'
|
||||
@@ -1608,9 +1618,11 @@ function buildPurchaseActionMessage(
|
||||
): string {
|
||||
const t = getBotTranslations(locale).purchase
|
||||
const summary = formatPurchaseSummary(locale, result)
|
||||
const participants =
|
||||
'participants' in result ? formatPurchaseParticipants(locale, result.participants) : null
|
||||
|
||||
if (result.status === 'confirmed' || result.status === 'already_confirmed') {
|
||||
return t.confirmed(summary)
|
||||
return participants ? `${t.confirmed(summary)}\n\n${participants}` : t.confirmed(summary)
|
||||
}
|
||||
|
||||
return t.cancelled(summary)
|
||||
@@ -1912,6 +1924,7 @@ export function registerPurchaseTopicIngestion(
|
||||
cacheTopicMessageRoute(ctx, 'purchase', route)
|
||||
|
||||
if (route.route === 'silent') {
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
@@ -1921,9 +1934,10 @@ export function registerPurchaseTopicIngestion(
|
||||
}
|
||||
|
||||
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
if (route.replyText) {
|
||||
await replyToPurchaseMessage(ctx, route.replyText)
|
||||
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
|
||||
rememberAssistantTurn(options.memoryStore, record, route.replyText)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1934,10 +1948,12 @@ export function registerPurchaseTopicIngestion(
|
||||
}
|
||||
|
||||
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
typingIndicator =
|
||||
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
|
||||
const pendingReply =
|
||||
@@ -1950,6 +1966,7 @@ export function registerPurchaseTopicIngestion(
|
||||
return await next()
|
||||
}
|
||||
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply)
|
||||
rememberAssistantTurn(options.memoryStore, record, buildPurchaseAcknowledgement(result, 'en'))
|
||||
} catch (error) {
|
||||
options.logger?.error(
|
||||
{
|
||||
@@ -2035,6 +2052,7 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
cacheTopicMessageRoute(ctx, 'purchase', route)
|
||||
|
||||
if (route.route === 'silent') {
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
@@ -2044,9 +2062,10 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
}
|
||||
|
||||
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
if (route.replyText) {
|
||||
await replyToPurchaseMessage(ctx, route.replyText)
|
||||
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
|
||||
rememberAssistantTurn(options.memoryStore, record, route.replyText)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2057,10 +2076,12 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
}
|
||||
|
||||
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
rememberUserTurn(options.memoryStore, record)
|
||||
typingIndicator =
|
||||
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
|
||||
const pendingReply =
|
||||
@@ -2081,6 +2102,11 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
}
|
||||
|
||||
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
|
||||
rememberAssistantTurn(
|
||||
options.memoryStore,
|
||||
record,
|
||||
buildPurchaseAcknowledgement(result, locale)
|
||||
)
|
||||
} catch (error) {
|
||||
options.logger?.error(
|
||||
{
|
||||
|
||||
@@ -69,6 +69,12 @@ const LIKELY_PURCHASE_PATTERN =
|
||||
const LIKELY_PAYMENT_PATTERN =
|
||||
/\b(?:paid rent|paid utilities|rent paid|utilities paid)\b|(?:^|[^\p{L}])(?:оплатил(?:а|и)?|заплатил(?:а|и)?)(?=$|[^\p{L}])/iu
|
||||
const LETTER_PATTERN = /\p{L}/u
|
||||
const DIRECT_BOT_ADDRESS_PATTERN =
|
||||
/^\s*(?:(?:ну|эй|слышь|слушай|hey|yo)\s*,?\s*)*(?:бот|bot)(?=$|[^\p{L}])/iu
|
||||
|
||||
export function looksLikeDirectBotAddress(text: string): boolean {
|
||||
return DIRECT_BOT_ADDRESS_PATTERN.test(text.trim())
|
||||
}
|
||||
|
||||
function normalizeRoute(value: string): TopicMessageRoute {
|
||||
return value === 'chat_reply' ||
|
||||
@@ -154,6 +160,21 @@ export function fallbackTopicMessageRoute(
|
||||
}
|
||||
}
|
||||
|
||||
if (isAddressed && PLANNING_PATTERN.test(normalized)) {
|
||||
return {
|
||||
route: 'chat_reply',
|
||||
replyText:
|
||||
input.locale === 'ru'
|
||||
? 'Похоже, ты пока прикидываешь. Когда захочешь мнение или реальную покупку записать, подключусь.'
|
||||
: "Sounds like you're still thinking it through. If you want an opinion or a real purchase recorded, I'm in.",
|
||||
helperKind: 'assistant',
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 66,
|
||||
reason: 'planning_advice'
|
||||
}
|
||||
}
|
||||
|
||||
if (!PLANNING_PATTERN.test(normalized) && LIKELY_PURCHASE_PATTERN.test(normalized)) {
|
||||
return {
|
||||
route: 'purchase_candidate',
|
||||
@@ -282,6 +303,9 @@ export function createOpenAiTopicMessageRouter(
|
||||
'Do not start purchase or payment workflows for planning, hypotheticals, negotiations, tests, or obvious jokes.',
|
||||
'Treat “stop”, “leave me alone”, “just thinking”, “not a purchase”, and similar messages as backoff or dismissal signals.',
|
||||
'When the user directly addresses the bot with small talk, joking, or testing, prefer chat_reply with one short sentence.',
|
||||
'In a purchase topic, if the user is discussing a possible future purchase and asks for an opinion, prefer chat_reply with a short contextual opinion instead of a workflow.',
|
||||
'Use the recent conversation when writing replyText. Do not ignore the already-established subject.',
|
||||
'If the user asks what you think about a price or quantity, mention the actual item/price from context when possible.',
|
||||
'Use topic_helper only when the message is a real question or request that likely needs household knowledge or a topic-specific helper.',
|
||||
'Use purchase_candidate only for a clear completed shared purchase.',
|
||||
'Use purchase_followup only when there is active purchase clarification and the latest message looks like a real answer to it.',
|
||||
@@ -305,6 +329,7 @@ export function createOpenAiTopicMessageRouter(
|
||||
`Topic role: ${input.topicRole}`,
|
||||
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
|
||||
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
|
||||
`Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`,
|
||||
`Active workflow: ${input.activeWorkflow ?? 'none'}`,
|
||||
buildRecentTurns(input),
|
||||
`Latest message:\n${input.messageText}`
|
||||
|
||||
Reference in New Issue
Block a user