mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 02:34:03 +00:00
feat(bot): add shared topic router
This commit is contained in:
@@ -9,11 +9,19 @@ 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,
|
||||
PurchaseMessageInterpreter
|
||||
} from './openai-purchase-interpreter'
|
||||
import {
|
||||
cacheTopicMessageRoute,
|
||||
getCachedTopicMessageRoute,
|
||||
type TopicMessageRouter,
|
||||
type TopicMessageRoutingResult
|
||||
} from './topic-message-router'
|
||||
import { startTypingIndicator } from './telegram-chat-action'
|
||||
import { stripExplicitBotMention } from './telegram-mentions'
|
||||
|
||||
@@ -30,20 +38,6 @@ const MONEY_SIGNAL_PATTERN =
|
||||
/\b\d+(?:[.,]\d{1,2})?\s*(?:₾|gel|lari|usd|\$)\b|\d+(?:[.,]\d{1,2})?\s*(?:лари|лри|tetri|тетри|доллар(?:а|ов)?)(?=$|[^\p{L}])|\b(?:for|за|на|до)\s+\d+(?:[.,]\d{1,2})?\b|\b(?:paid|spent)\s+\d+(?:[.,]\d{1,2})?\b|(?:^|[^\p{L}])(?:заплатил(?:а|и)?|потратил(?:а|и)?|отдал(?:а|и)?|выложил(?:а|и)?|сторговался(?:\s+до)?)(?:\s+\d+(?:[.,]\d{1,2})?|\s+до\s+\d+(?:[.,]\d{1,2})?)(?=$|[^\p{L}])/iu
|
||||
const STANDALONE_NUMBER_PATTERN = /\b\d+(?:[.,]\d{1,2})?\b/gu
|
||||
|
||||
type PurchaseTopicEngagement =
|
||||
| {
|
||||
kind: 'direct'
|
||||
showProcessingReply: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'clarification'
|
||||
showProcessingReply: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'likely_purchase'
|
||||
showProcessingReply: true
|
||||
}
|
||||
|
||||
type StoredPurchaseProcessingStatus =
|
||||
| 'pending_confirmation'
|
||||
| 'clarification_needed'
|
||||
@@ -202,6 +196,7 @@ export type PurchaseProposalAmountCorrectionResult =
|
||||
|
||||
export interface PurchaseMessageIngestionRepository {
|
||||
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
||||
clearClarificationContext?(record: PurchaseTopicRecord): Promise<void>
|
||||
save(
|
||||
record: PurchaseTopicRecord,
|
||||
interpreter?: PurchaseMessageInterpreter,
|
||||
@@ -285,36 +280,6 @@ function looksLikeLikelyCompletedPurchase(rawText: string): boolean {
|
||||
return Array.from(rawText.matchAll(STANDALONE_NUMBER_PATTERN)).length === 1
|
||||
}
|
||||
|
||||
async function resolvePurchaseTopicEngagement(
|
||||
ctx: Pick<Context, 'msg' | 'me'>,
|
||||
record: PurchaseTopicRecord,
|
||||
repository: Pick<PurchaseMessageIngestionRepository, 'hasClarificationContext'>
|
||||
): Promise<PurchaseTopicEngagement | null> {
|
||||
const hasExplicitMention = stripExplicitBotMention(ctx) !== null
|
||||
if (hasExplicitMention || isReplyToCurrentBot(ctx)) {
|
||||
return {
|
||||
kind: 'direct',
|
||||
showProcessingReply: looksLikeLikelyCompletedPurchase(record.rawText)
|
||||
}
|
||||
}
|
||||
|
||||
if (await repository.hasClarificationContext(record)) {
|
||||
return {
|
||||
kind: 'clarification',
|
||||
showProcessingReply: false
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikeLikelyCompletedPurchase(record.rawText)) {
|
||||
return {
|
||||
kind: 'likely_purchase',
|
||||
showProcessingReply: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeInterpretation(
|
||||
interpretation: PurchaseInterpretation | null,
|
||||
parserError: string | null
|
||||
@@ -516,6 +481,22 @@ async function sendPurchaseProcessingReply(
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowProcessingReply(
|
||||
ctx: Pick<Context, 'msg' | 'me'>,
|
||||
record: PurchaseTopicRecord,
|
||||
route: TopicMessageRoutingResult
|
||||
): boolean {
|
||||
if (route.route !== 'purchase_candidate' || !route.shouldStartTyping) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stripExplicitBotMention(ctx) !== null || isReplyToCurrentBot(ctx)) {
|
||||
return looksLikeLikelyCompletedPurchase(record.rawText)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function finalizePurchaseReply(
|
||||
ctx: Context,
|
||||
pendingReply: PendingPurchaseReply | null,
|
||||
@@ -943,6 +924,23 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
return Boolean(clarificationContext && clarificationContext.length > 0)
|
||||
},
|
||||
|
||||
async clearClarificationContext(record) {
|
||||
await db
|
||||
.update(schema.purchaseMessages)
|
||||
.set({
|
||||
processingStatus: 'ignored_not_purchase',
|
||||
needsReview: 0
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.purchaseMessages.householdId, record.householdId),
|
||||
eq(schema.purchaseMessages.senderTelegramUserId, record.senderTelegramUserId),
|
||||
eq(schema.purchaseMessages.telegramThreadId, record.threadId),
|
||||
eq(schema.purchaseMessages.processingStatus, 'clarification_needed')
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
async save(record, interpreter, defaultCurrency, options) {
|
||||
const matchedMember = await db
|
||||
.select({ id: schema.members.id })
|
||||
@@ -1441,6 +1439,118 @@ async function resolveAssistantConfig(
|
||||
}
|
||||
}
|
||||
|
||||
function memoryKeyForRecord(record: PurchaseTopicRecord): string {
|
||||
return conversationMemoryKey({
|
||||
telegramUserId: record.senderTelegramUserId,
|
||||
telegramChatId: record.chatId,
|
||||
isPrivateChat: false
|
||||
})
|
||||
}
|
||||
|
||||
function appendConversation(
|
||||
memoryStore: AssistantConversationMemoryStore | undefined,
|
||||
record: PurchaseTopicRecord,
|
||||
userText: string,
|
||||
assistantText: string
|
||||
): void {
|
||||
if (!memoryStore) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = memoryKeyForRecord(record)
|
||||
memoryStore.appendTurn(key, {
|
||||
role: 'user',
|
||||
text: userText
|
||||
})
|
||||
memoryStore.appendTurn(key, {
|
||||
role: 'assistant',
|
||||
text: assistantText
|
||||
})
|
||||
}
|
||||
|
||||
async function routePurchaseTopicMessage(input: {
|
||||
ctx: Pick<Context, 'msg' | 'me'>
|
||||
record: PurchaseTopicRecord
|
||||
locale: BotLocale
|
||||
repository: Pick<
|
||||
PurchaseMessageIngestionRepository,
|
||||
'hasClarificationContext' | 'clearClarificationContext'
|
||||
>
|
||||
router: TopicMessageRouter | undefined
|
||||
memoryStore: AssistantConversationMemoryStore | undefined
|
||||
assistantContext?: string | null
|
||||
assistantTone?: string | null
|
||||
}): Promise<TopicMessageRoutingResult> {
|
||||
if (!input.router) {
|
||||
const hasExplicitMention = stripExplicitBotMention(input.ctx) !== null
|
||||
const isReply = isReplyToCurrentBot(input.ctx)
|
||||
const hasClarificationContext = await input.repository.hasClarificationContext(input.record)
|
||||
|
||||
if (hasExplicitMention || isReply) {
|
||||
return {
|
||||
route: 'purchase_candidate',
|
||||
replyText: null,
|
||||
helperKind: 'purchase',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 75,
|
||||
reason: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
|
||||
if (hasClarificationContext) {
|
||||
return {
|
||||
route: 'purchase_followup',
|
||||
replyText: null,
|
||||
helperKind: 'purchase',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 75,
|
||||
reason: 'legacy_clarification'
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikeLikelyCompletedPurchase(input.record.rawText)) {
|
||||
return {
|
||||
route: 'purchase_candidate',
|
||||
replyText: null,
|
||||
helperKind: 'purchase',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 75,
|
||||
reason: 'legacy_likely_purchase'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 80,
|
||||
reason: 'legacy_silent'
|
||||
}
|
||||
}
|
||||
|
||||
const key = memoryKeyForRecord(input.record)
|
||||
const recentTurns = input.memoryStore?.get(key).turns ?? []
|
||||
|
||||
return input.router({
|
||||
locale: input.locale,
|
||||
topicRole: 'purchase',
|
||||
messageText: input.record.rawText,
|
||||
isExplicitMention: stripExplicitBotMention(input.ctx) !== null,
|
||||
isReplyToBot: isReplyToCurrentBot(input.ctx),
|
||||
activeWorkflow: (await input.repository.hasClarificationContext(input.record))
|
||||
? 'purchase_clarification'
|
||||
: null,
|
||||
assistantContext: input.assistantContext ?? null,
|
||||
assistantTone: input.assistantTone ?? null,
|
||||
recentTurns
|
||||
})
|
||||
}
|
||||
|
||||
async function handlePurchaseMessageResult(
|
||||
ctx: Context,
|
||||
record: PurchaseTopicRecord,
|
||||
@@ -1766,6 +1876,8 @@ export function registerPurchaseTopicIngestion(
|
||||
repository: PurchaseMessageIngestionRepository,
|
||||
options: {
|
||||
interpreter?: PurchaseMessageInterpreter
|
||||
router?: TopicMessageRouter
|
||||
memoryStore?: AssistantConversationMemoryStore
|
||||
logger?: Logger
|
||||
} = {}
|
||||
): void {
|
||||
@@ -1787,20 +1899,54 @@ export function registerPurchaseTopicIngestion(
|
||||
let typingIndicator: ReturnType<typeof startTypingIndicator> | null = null
|
||||
|
||||
try {
|
||||
const engagement = await resolvePurchaseTopicEngagement(ctx, record, repository)
|
||||
if (!engagement) {
|
||||
const route =
|
||||
getCachedTopicMessageRoute(ctx, 'purchase') ??
|
||||
(await routePurchaseTopicMessage({
|
||||
ctx,
|
||||
record,
|
||||
locale: 'en',
|
||||
repository,
|
||||
router: options.router,
|
||||
memoryStore: options.memoryStore
|
||||
}))
|
||||
cacheTopicMessageRoute(ctx, 'purchase', route)
|
||||
|
||||
if (route.route === 'silent') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null
|
||||
if (route.shouldClearWorkflow) {
|
||||
await repository.clearClarificationContext?.(record)
|
||||
}
|
||||
|
||||
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
|
||||
if (route.replyText) {
|
||||
await replyToPurchaseMessage(ctx, route.replyText)
|
||||
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (route.route === 'topic_helper') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
typingIndicator =
|
||||
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
|
||||
const pendingReply =
|
||||
options.interpreter && engagement.showProcessingReply
|
||||
options.interpreter && shouldShowProcessingReply(ctx, record, route)
|
||||
? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing)
|
||||
: null
|
||||
const result = await repository.save(record, options.interpreter, 'GEL')
|
||||
|
||||
if (engagement.kind === 'direct' && result.status === 'ignored_not_purchase') {
|
||||
if (result.status === 'ignored_not_purchase') {
|
||||
return await next()
|
||||
}
|
||||
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply)
|
||||
@@ -1828,6 +1974,8 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
repository: PurchaseMessageIngestionRepository,
|
||||
options: {
|
||||
interpreter?: PurchaseMessageInterpreter
|
||||
router?: TopicMessageRouter
|
||||
memoryStore?: AssistantConversationMemoryStore
|
||||
logger?: Logger
|
||||
} = {}
|
||||
): void {
|
||||
@@ -1864,13 +2012,6 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
let typingIndicator: ReturnType<typeof startTypingIndicator> | null = null
|
||||
|
||||
try {
|
||||
const engagement = await resolvePurchaseTopicEngagement(ctx, record, repository)
|
||||
if (!engagement) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null
|
||||
const [billingSettings, assistantConfig] = await Promise.all([
|
||||
householdConfigurationRepository.getHouseholdBillingSettings(record.householdId),
|
||||
resolveAssistantConfig(householdConfigurationRepository, record.householdId)
|
||||
@@ -1879,8 +2020,51 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
householdConfigurationRepository,
|
||||
record.householdId
|
||||
)
|
||||
const route =
|
||||
getCachedTopicMessageRoute(ctx, 'purchase') ??
|
||||
(await routePurchaseTopicMessage({
|
||||
ctx,
|
||||
record,
|
||||
locale,
|
||||
repository,
|
||||
router: options.router,
|
||||
memoryStore: options.memoryStore,
|
||||
assistantContext: assistantConfig.assistantContext,
|
||||
assistantTone: assistantConfig.assistantTone
|
||||
}))
|
||||
cacheTopicMessageRoute(ctx, 'purchase', route)
|
||||
|
||||
if (route.route === 'silent') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
if (route.shouldClearWorkflow) {
|
||||
await repository.clearClarificationContext?.(record)
|
||||
}
|
||||
|
||||
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
|
||||
if (route.replyText) {
|
||||
await replyToPurchaseMessage(ctx, route.replyText)
|
||||
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (route.route === 'topic_helper') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
typingIndicator =
|
||||
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
|
||||
const pendingReply =
|
||||
options.interpreter && engagement.showProcessingReply
|
||||
options.interpreter && shouldShowProcessingReply(ctx, record, route)
|
||||
? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing)
|
||||
: null
|
||||
const result = await repository.save(
|
||||
@@ -1892,7 +2076,7 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
assistantTone: assistantConfig.assistantTone
|
||||
}
|
||||
)
|
||||
if (engagement.kind === 'direct' && result.status === 'ignored_not_purchase') {
|
||||
if (result.status === 'ignored_not_purchase') {
|
||||
return await next()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user