mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): unified topic processor replacing router+interpreter stack
Replace 3-layer architecture (gpt-5-nano router + gpt-4o-mini interpreter) with single unified topic processor (gpt-4o-mini) for simplified message handling. New components: - HouseholdContextCache: TTL-based caching (5 min) for household config data - TopicProcessor: Unified classification + parsing with structured JSON output Key changes: - Renamed ASSISTANT_ROUTER_MODEL → TOPIC_PROCESSOR_MODEL - Added TOPIC_PROCESSOR_TIMEOUT_MS (default 10s) - Refactored save() → saveWithInterpretation() for pre-parsed interpretations - Removed deprecated createOpenAiTopicMessageRouter and ~300 lines legacy code - Fixed typing indicator to only start when needed (purchase routes) - Fixed amount formatting: convert minor units to major for rawText Routes: silent, chat_reply, purchase, purchase_clarification, payment, payment_clarification, topic_helper, dismiss_workflow All 212 bot tests pass. Typecheck, lint, format, build clean.
This commit is contained in:
@@ -16,9 +16,10 @@ MINI_APP_ALLOWED_ORIGINS=http://localhost:5173
|
|||||||
|
|
||||||
# Parsing / AI
|
# Parsing / AI
|
||||||
OPENAI_API_KEY=your-openai-api-key
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
PARSER_MODEL=gpt-4o-mini
|
|
||||||
PURCHASE_PARSER_MODEL=gpt-4o-mini
|
PURCHASE_PARSER_MODEL=gpt-4o-mini
|
||||||
ASSISTANT_MODEL=gpt-4o-mini
|
ASSISTANT_MODEL=gpt-4o-mini
|
||||||
|
TOPIC_PROCESSOR_MODEL=gpt-4o-mini
|
||||||
|
TOPIC_PROCESSOR_TIMEOUT_MS=10000
|
||||||
ASSISTANT_TIMEOUT_MS=20000
|
ASSISTANT_TIMEOUT_MS=20000
|
||||||
ASSISTANT_MEMORY_MAX_TURNS=12
|
ASSISTANT_MEMORY_MAX_TURNS=12
|
||||||
ASSISTANT_RATE_LIMIT_BURST=5
|
ASSISTANT_RATE_LIMIT_BURST=5
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This is not a toy Telegram bot repo with a thin webhook and some string parsing.
|
|||||||
- deterministic money-safe settlement logic with integer minor-unit math
|
- deterministic money-safe settlement logic with integer minor-unit math
|
||||||
- a hexagonal TypeScript monorepo with explicit domain / application / ports / adapter boundaries
|
- a hexagonal TypeScript monorepo with explicit domain / application / ports / adapter boundaries
|
||||||
- real operational concerns: idempotency, onboarding flows, localized UX, bot topic setup, reminder scheduling, Terraform-managed infrastructure
|
- real operational concerns: idempotency, onboarding flows, localized UX, bot topic setup, reminder scheduling, Terraform-managed infrastructure
|
||||||
- a layered LLM architecture: a cheap first-pass topic router (`gpt-5-nano`) decides whether to stay silent, reply lightly, continue a workflow, or invoke a heavier helper
|
- a unified topic processor (`gpt-4o-mini`) that classifies messages and extracts structured data in a single LLM call
|
||||||
- a product that mixes structured command flows with LLM-assisted parsing while keeping writes deterministic
|
- a product that mixes structured command flows with LLM-assisted parsing while keeping writes deterministic
|
||||||
|
|
||||||
## Current Product Scope
|
## Current Product Scope
|
||||||
@@ -139,7 +139,7 @@ For a fuller setup walkthrough, see the [development setup runbook](docs/runbook
|
|||||||
Some product choices here are intentional:
|
Some product choices here are intentional:
|
||||||
|
|
||||||
- LLMs help interpret messy purchase/payment phrasing, but final writes are still explicit, structured, and confirmable.
|
- LLMs help interpret messy purchase/payment phrasing, but final writes are still explicit, structured, and confirmable.
|
||||||
- The bot uses a separate first-pass AI router, defaulting to `gpt-5-nano`, to classify topic messages before invoking the fuller assistant or parser models. That keeps casual chatter, jokes, and ambiguous messages from unnecessarily hitting heavier paths, while still letting the bot respond naturally when it is directly addressed.
|
- The bot uses a unified topic processor (`gpt-4o-mini`) that classifies messages and extracts purchase/payment data in a single call. This simplifies the architecture while keeping casual chatter from hitting heavier paths.
|
||||||
- Topic-specific ingestion stays separate from the general assistant so finance actions do not degrade into vague chat behavior.
|
- Topic-specific ingestion stays separate from the general assistant so finance actions do not degrade into vague chat behavior.
|
||||||
- Telegram UX is treated as a real product surface: onboarding, confirmation buttons, topic setup, tagged replies, and localization are part of the design, not afterthoughts.
|
- Telegram UX is treated as a real product surface: onboarding, confirmation buttons, topic setup, tagged replies, and localization are part of the design, not afterthoughts.
|
||||||
- Infra is versioned alongside the app so deployability, alerts, and runtime configuration are reviewable in the same repo.
|
- Infra is versioned alongside the app so deployability, alerts, and runtime configuration are reviewable in the same repo.
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export interface BotRuntimeConfig {
|
|||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
purchaseParserModel: string
|
purchaseParserModel: string
|
||||||
assistantModel: string
|
assistantModel: string
|
||||||
assistantRouterModel: string
|
topicProcessorModel: string
|
||||||
|
topicProcessorTimeoutMs: number
|
||||||
assistantTimeoutMs: number
|
assistantTimeoutMs: number
|
||||||
assistantMemoryMaxTurns: number
|
assistantMemoryMaxTurns: number
|
||||||
assistantRateLimitBurst: number
|
assistantRateLimitBurst: number
|
||||||
@@ -129,7 +130,12 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
reminderJobsEnabled,
|
reminderJobsEnabled,
|
||||||
purchaseParserModel: env.PURCHASE_PARSER_MODEL?.trim() || 'gpt-4o-mini',
|
purchaseParserModel: env.PURCHASE_PARSER_MODEL?.trim() || 'gpt-4o-mini',
|
||||||
assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-4o-mini',
|
assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-4o-mini',
|
||||||
assistantRouterModel: env.ASSISTANT_ROUTER_MODEL?.trim() || 'gpt-5-nano',
|
topicProcessorModel: env.TOPIC_PROCESSOR_MODEL?.trim() || 'gpt-4o-mini',
|
||||||
|
topicProcessorTimeoutMs: parsePositiveInteger(
|
||||||
|
env.TOPIC_PROCESSOR_TIMEOUT_MS,
|
||||||
|
10_000,
|
||||||
|
'TOPIC_PROCESSOR_TIMEOUT_MS'
|
||||||
|
),
|
||||||
assistantTimeoutMs: parsePositiveInteger(
|
assistantTimeoutMs: parsePositiveInteger(
|
||||||
env.ASSISTANT_TIMEOUT_MS,
|
env.ASSISTANT_TIMEOUT_MS,
|
||||||
20_000,
|
20_000,
|
||||||
|
|||||||
@@ -469,6 +469,9 @@ function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
|||||||
async hasClarificationContext(record) {
|
async hasClarificationContext(record) {
|
||||||
return clarificationKeys.has(key(record))
|
return clarificationKeys.has(key(record))
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async save(record) {
|
async save(record) {
|
||||||
const threadKey = key(record)
|
const threadKey = key(record)
|
||||||
|
|
||||||
@@ -1414,25 +1417,12 @@ Confirm or cancel below.`,
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('reuses the purchase-topic route instead of calling the shared router twice', async () => {
|
test('uses topic processor for classification and assistant for response', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
let assistantCalls = 0
|
let assistantCalls = 0
|
||||||
let routerCalls = 0
|
let processorCalls = 0
|
||||||
const householdConfigurationRepository = createBoundHouseholdRepository('purchase')
|
const householdConfigurationRepository = createBoundHouseholdRepository('purchase')
|
||||||
const topicRouter = async () => {
|
|
||||||
routerCalls += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
route: 'topic_helper' as const,
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'assistant' as const,
|
|
||||||
shouldStartTyping: true,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 96,
|
|
||||||
reason: 'question'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bot.api.config.use(async (_prev, method, payload) => {
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
calls.push({ method, payload })
|
calls.push({ method, payload })
|
||||||
@@ -1463,7 +1453,10 @@ Confirm or cancel below.`,
|
|||||||
householdConfigurationRepository,
|
householdConfigurationRepository,
|
||||||
createPurchaseRepository(),
|
createPurchaseRepository(),
|
||||||
{
|
{
|
||||||
router: topicRouter
|
topicProcessor: async () => {
|
||||||
|
processorCalls += 1
|
||||||
|
return { route: 'topic_helper', reason: 'test' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1482,7 +1475,6 @@ Confirm or cancel below.`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
topicRouter,
|
|
||||||
purchaseRepository: createPurchaseRepository(),
|
purchaseRepository: createPurchaseRepository(),
|
||||||
purchaseInterpreter: async () => null,
|
purchaseInterpreter: async () => null,
|
||||||
householdConfigurationRepository,
|
householdConfigurationRepository,
|
||||||
@@ -1500,7 +1492,7 @@ Confirm or cancel below.`,
|
|||||||
|
|
||||||
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||||||
|
|
||||||
expect(routerCalls).toBe(1)
|
expect(processorCalls).toBe(1)
|
||||||
expect(assistantCalls).toBe(1)
|
expect(assistantCalls).toBe(1)
|
||||||
expect(calls).toEqual(
|
expect(calls).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
|||||||
48
apps/bot/src/household-context-cache.ts
Normal file
48
apps/bot/src/household-context-cache.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { BotLocale } from './i18n'
|
||||||
|
|
||||||
|
export interface CachedHouseholdContext {
|
||||||
|
householdContext: string | null
|
||||||
|
assistantTone: string | null
|
||||||
|
defaultCurrency: 'GEL' | 'USD'
|
||||||
|
locale: BotLocale
|
||||||
|
cachedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
context: CachedHouseholdContext
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HouseholdContextCache {
|
||||||
|
private cache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
constructor(private ttlMs: number = 5 * 60_000) {}
|
||||||
|
|
||||||
|
async get(
|
||||||
|
householdId: string,
|
||||||
|
loader: () => Promise<CachedHouseholdContext>
|
||||||
|
): Promise<CachedHouseholdContext> {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = this.cache.get(householdId)
|
||||||
|
|
||||||
|
if (entry && entry.expiresAt > now) {
|
||||||
|
return entry.context
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await loader()
|
||||||
|
this.cache.set(householdId, {
|
||||||
|
context,
|
||||||
|
expiresAt: now + this.ttlMs
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(householdId: string): void {
|
||||||
|
this.cache.delete(householdId)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ import { getBotRuntimeConfig } from './config'
|
|||||||
import { registerHouseholdSetupCommands } from './household-setup'
|
import { registerHouseholdSetupCommands } from './household-setup'
|
||||||
import { createOpenAiChatAssistant } from './openai-chat-assistant'
|
import { createOpenAiChatAssistant } from './openai-chat-assistant'
|
||||||
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
||||||
import { createOpenAiTopicMessageRouter } from './topic-message-router'
|
import { createTopicProcessor } from './topic-processor'
|
||||||
|
import { HouseholdContextCache } from './household-context-cache'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
registerConfiguredPurchaseTopicIngestion
|
registerConfiguredPurchaseTopicIngestion
|
||||||
@@ -153,11 +154,12 @@ const conversationalAssistant = createOpenAiChatAssistant(
|
|||||||
runtime.assistantModel,
|
runtime.assistantModel,
|
||||||
runtime.assistantTimeoutMs
|
runtime.assistantTimeoutMs
|
||||||
)
|
)
|
||||||
const topicMessageRouter = createOpenAiTopicMessageRouter(
|
const topicProcessor = createTopicProcessor(
|
||||||
runtime.openaiApiKey,
|
runtime.openaiApiKey,
|
||||||
runtime.assistantRouterModel,
|
runtime.topicProcessorModel,
|
||||||
Math.min(runtime.assistantTimeoutMs, 5_000)
|
runtime.topicProcessorTimeoutMs
|
||||||
)
|
)
|
||||||
|
const householdContextCache = new HouseholdContextCache()
|
||||||
const anonymousFeedbackRepositoryClients = new Map<
|
const anonymousFeedbackRepositoryClients = new Map<
|
||||||
string,
|
string,
|
||||||
ReturnType<typeof createDbAnonymousFeedbackRepository>
|
ReturnType<typeof createDbAnonymousFeedbackRepository>
|
||||||
@@ -254,9 +256,10 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
|
|||||||
householdConfigurationRepositoryClient.repository,
|
householdConfigurationRepositoryClient.repository,
|
||||||
purchaseRepositoryClient.repository,
|
purchaseRepositoryClient.repository,
|
||||||
{
|
{
|
||||||
...(topicMessageRouter
|
...(topicProcessor
|
||||||
? {
|
? {
|
||||||
router: topicMessageRouter,
|
topicProcessor,
|
||||||
|
contextCache: householdContextCache,
|
||||||
memoryStore: assistantMemoryStore,
|
memoryStore: assistantMemoryStore,
|
||||||
...(topicMessageHistoryRepositoryClient
|
...(topicMessageHistoryRepositoryClient
|
||||||
? {
|
? {
|
||||||
@@ -281,9 +284,10 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
|
|||||||
financeServiceForHousehold,
|
financeServiceForHousehold,
|
||||||
paymentConfirmationServiceForHousehold,
|
paymentConfirmationServiceForHousehold,
|
||||||
{
|
{
|
||||||
...(topicMessageRouter
|
...(topicProcessor
|
||||||
? {
|
? {
|
||||||
router: topicMessageRouter,
|
topicProcessor,
|
||||||
|
contextCache: householdContextCache,
|
||||||
memoryStore: assistantMemoryStore,
|
memoryStore: assistantMemoryStore,
|
||||||
...(topicMessageHistoryRepositoryClient
|
...(topicMessageHistoryRepositoryClient
|
||||||
? {
|
? {
|
||||||
@@ -476,11 +480,6 @@ if (
|
|||||||
assistant: conversationalAssistant
|
assistant: conversationalAssistant
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(topicMessageRouter
|
|
||||||
? {
|
|
||||||
topicRouter: topicMessageRouter
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
logger: getLogger('dm-assistant')
|
logger: getLogger('dm-assistant')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -512,11 +511,6 @@ if (
|
|||||||
assistant: conversationalAssistant
|
assistant: conversationalAssistant
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(topicMessageRouter
|
|
||||||
? {
|
|
||||||
topicRouter: topicMessageRouter
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
logger: getLogger('dm-assistant')
|
logger: getLogger('dm-assistant')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveConfiguredPaymentTopicRecord,
|
resolveConfiguredPaymentTopicRecord,
|
||||||
type PaymentTopicCandidate
|
type PaymentTopicCandidate
|
||||||
} from './payment-topic-ingestion'
|
} from './payment-topic-ingestion'
|
||||||
|
import type { TopicProcessor } from './topic-processor'
|
||||||
|
|
||||||
function candidate(overrides: Partial<PaymentTopicCandidate> = {}): PaymentTopicCandidate {
|
function candidate(overrides: Partial<PaymentTopicCandidate> = {}): PaymentTopicCandidate {
|
||||||
return {
|
return {
|
||||||
@@ -231,6 +232,39 @@ function createPaymentConfirmationService(): PaymentConfirmationService & {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mock topic processor that mimics LLM responses for testing
|
||||||
|
function createMockPaymentTopicProcessor(
|
||||||
|
route: 'payment' | 'silent' | 'topic_helper' | 'payment_clarification' | 'chat_reply' = 'payment'
|
||||||
|
): TopicProcessor {
|
||||||
|
return async () => {
|
||||||
|
if (route === 'silent') {
|
||||||
|
return { route: 'silent', reason: 'test' }
|
||||||
|
}
|
||||||
|
if (route === 'topic_helper') {
|
||||||
|
return { route: 'topic_helper', reason: 'test' }
|
||||||
|
}
|
||||||
|
if (route === 'chat_reply') {
|
||||||
|
return { route: 'chat_reply', replyText: 'Hello!', reason: 'test' }
|
||||||
|
}
|
||||||
|
if (route === 'payment_clarification') {
|
||||||
|
return {
|
||||||
|
route: 'payment_clarification',
|
||||||
|
clarificationQuestion: 'What kind of payment?',
|
||||||
|
reason: 'test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to payment route
|
||||||
|
return {
|
||||||
|
route: 'payment',
|
||||||
|
kind: 'rent',
|
||||||
|
amountMinor: '47250',
|
||||||
|
currency: 'GEL',
|
||||||
|
confidence: 95,
|
||||||
|
reason: 'test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('resolveConfiguredPaymentTopicRecord', () => {
|
describe('resolveConfiguredPaymentTopicRecord', () => {
|
||||||
test('returns record when the topic role is payments', () => {
|
test('returns record when the topic role is payments', () => {
|
||||||
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
|
const record = resolveConfiguredPaymentTopicRecord(candidate(), {
|
||||||
@@ -332,7 +366,8 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: createMockPaymentTopicProcessor() }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
|
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
|
||||||
@@ -403,12 +438,36 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
|
|
||||||
const paymentConfirmationService = createPaymentConfirmationService()
|
const paymentConfirmationService = createPaymentConfirmationService()
|
||||||
|
|
||||||
|
// Smart mock that returns clarification for vague messages, payment for clear ones
|
||||||
|
const smartTopicProcessor: TopicProcessor = async (input) => {
|
||||||
|
const text = input.messageText.toLowerCase()
|
||||||
|
// Vague messages like "готово" (done) need clarification
|
||||||
|
if (text === 'готово' || text === 'done') {
|
||||||
|
return {
|
||||||
|
route: 'payment_clarification',
|
||||||
|
clarificationQuestion:
|
||||||
|
'Пока не могу подтвердить эту оплату. Уточните, это аренда или коммуналка, и при необходимости напишите сумму и валюту.',
|
||||||
|
reason: 'test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Messages with rent keywords can proceed as payment
|
||||||
|
return {
|
||||||
|
route: 'payment',
|
||||||
|
kind: 'rent',
|
||||||
|
amountMinor: '47250',
|
||||||
|
currency: 'GEL',
|
||||||
|
confidence: 95,
|
||||||
|
reason: 'test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerConfiguredPaymentTopicIngestion(
|
registerConfiguredPaymentTopicIngestion(
|
||||||
bot,
|
bot,
|
||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: smartTopicProcessor }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('готово') as never)
|
await bot.handleUpdate(paymentUpdate('готово') as never)
|
||||||
@@ -481,14 +540,10 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => createPaymentConfirmationService(),
|
() => createPaymentConfirmationService(),
|
||||||
{
|
{
|
||||||
router: async () => ({
|
topicProcessor: async () => ({
|
||||||
route: 'payment_followup',
|
route: 'dismiss_workflow',
|
||||||
replyText: null,
|
replyText: null,
|
||||||
helperKind: 'payment',
|
reason: 'test'
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 90,
|
|
||||||
reason: 'llm_followup_guess'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -534,7 +589,8 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: createMockPaymentTopicProcessor() }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
|
await bot.handleUpdate(paymentUpdate('за жилье закинул') as never)
|
||||||
@@ -605,7 +661,8 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: createMockPaymentTopicProcessor('silent') }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('Так так)') as never)
|
await bot.handleUpdate(paymentUpdate('Так так)') as never)
|
||||||
@@ -637,7 +694,8 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: createMockPaymentTopicProcessor() }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('/unsetup') as never)
|
await bot.handleUpdate(paymentUpdate('/unsetup') as never)
|
||||||
@@ -678,7 +736,8 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: createMockPaymentTopicProcessor('topic_helper') }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('@household_test_bot как жизнь?') as never)
|
await bot.handleUpdate(paymentUpdate('@household_test_bot как жизнь?') as never)
|
||||||
@@ -720,7 +779,8 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
createHouseholdRepository() as never,
|
createHouseholdRepository() as never,
|
||||||
promptRepository,
|
promptRepository,
|
||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => paymentConfirmationService
|
() => paymentConfirmationService,
|
||||||
|
{ topicProcessor: createMockPaymentTopicProcessor() }
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot.handleUpdate(paymentUpdate('@household_test_bot за жилье закинул') as never)
|
await bot.handleUpdate(paymentUpdate('@household_test_bot за жилье закинул') as never)
|
||||||
@@ -765,13 +825,9 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => createPaymentConfirmationService(),
|
() => createPaymentConfirmationService(),
|
||||||
{
|
{
|
||||||
router: async () => ({
|
topicProcessor: async () => ({
|
||||||
route: 'chat_reply',
|
route: 'chat_reply',
|
||||||
replyText: 'Тут. Если это про оплату, разберёмся.',
|
replyText: 'Тут. Если это про оплату, разберёмся.',
|
||||||
helperKind: null,
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 94,
|
|
||||||
reason: 'smalltalk'
|
reason: 'smalltalk'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -831,13 +887,9 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
|||||||
() => createFinanceService(),
|
() => createFinanceService(),
|
||||||
() => createPaymentConfirmationService(),
|
() => createPaymentConfirmationService(),
|
||||||
{
|
{
|
||||||
router: async () => ({
|
topicProcessor: async () => ({
|
||||||
route: 'dismiss_workflow',
|
route: 'dismiss_workflow',
|
||||||
replyText: 'Окей, молчу.',
|
replyText: 'Окей, молчу.',
|
||||||
helperKind: null,
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: true,
|
|
||||||
confidence: 97,
|
|
||||||
reason: 'backoff'
|
reason: 'backoff'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
|
import type { FinanceCommandService, PaymentConfirmationService } from '@household/application'
|
||||||
import { instantFromEpochSeconds, nowInstant, type Instant } from '@household/domain'
|
import { instantFromEpochSeconds, Money, nowInstant, type Instant } from '@household/domain'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type {
|
import type {
|
||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
import { getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
import type { AssistantConversationMemoryStore } from './assistant-state'
|
import type { AssistantConversationMemoryStore } from './assistant-state'
|
||||||
import { conversationMemoryKey } from './assistant-state'
|
import { conversationMemoryKey } from './assistant-state'
|
||||||
import { buildConversationContext } from './conversation-orchestrator'
|
|
||||||
import {
|
import {
|
||||||
formatPaymentBalanceReplyText,
|
formatPaymentBalanceReplyText,
|
||||||
formatPaymentProposalText,
|
formatPaymentProposalText,
|
||||||
@@ -21,11 +21,7 @@ import {
|
|||||||
parsePaymentProposalPayload,
|
parsePaymentProposalPayload,
|
||||||
synthesizePaymentConfirmationText
|
synthesizePaymentConfirmationText
|
||||||
} from './payment-proposals'
|
} from './payment-proposals'
|
||||||
import {
|
import type { TopicMessageRouter } from './topic-message-router'
|
||||||
cacheTopicMessageRoute,
|
|
||||||
getCachedTopicMessageRoute,
|
|
||||||
type TopicMessageRouter
|
|
||||||
} from './topic-message-router'
|
|
||||||
import {
|
import {
|
||||||
persistTopicHistoryMessage,
|
persistTopicHistoryMessage,
|
||||||
telegramMessageIdFromMessage,
|
telegramMessageIdFromMessage,
|
||||||
@@ -240,89 +236,6 @@ async function persistIncomingTopicMessage(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function routePaymentTopicMessage(input: {
|
|
||||||
record: PaymentTopicRecord
|
|
||||||
locale: BotLocale
|
|
||||||
topicRole: 'payments'
|
|
||||||
isExplicitMention: boolean
|
|
||||||
isReplyToBot: boolean
|
|
||||||
activeWorkflow: 'payment_clarification' | 'payment_confirmation' | null
|
|
||||||
assistantContext: string | null
|
|
||||||
assistantTone: string | null
|
|
||||||
memoryStore: AssistantConversationMemoryStore | undefined
|
|
||||||
historyRepository: TopicMessageHistoryRepository | undefined
|
|
||||||
router: TopicMessageRouter | undefined
|
|
||||||
}) {
|
|
||||||
if (!input.router) {
|
|
||||||
return input.activeWorkflow
|
|
||||||
? {
|
|
||||||
route: 'payment_followup' as const,
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'payment' as const,
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 75,
|
|
||||||
reason: 'legacy_payment_followup'
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
route: 'payment_candidate' as const,
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'payment' as const,
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 75,
|
|
||||||
reason: 'legacy_payment_candidate'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
replyToBot: input.isReplyToBot,
|
|
||||||
directBotAddress: false,
|
|
||||||
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: conversationContext.explicitMention,
|
|
||||||
isReplyToBot: conversationContext.replyToBot,
|
|
||||||
activeWorkflow: input.activeWorkflow,
|
|
||||||
engagementAssessment: conversationContext.engagement,
|
|
||||||
assistantContext: input.assistantContext,
|
|
||||||
assistantTone: input.assistantTone,
|
|
||||||
recentTurns: input.memoryStore?.get(memoryKeyForRecord(input.record)).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
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPaymentAcknowledgement(
|
export function buildPaymentAcknowledgement(
|
||||||
locale: BotLocale,
|
locale: BotLocale,
|
||||||
result:
|
result:
|
||||||
@@ -457,6 +370,8 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
|
paymentServiceForHousehold: (householdId: string) => PaymentConfirmationService,
|
||||||
options: {
|
options: {
|
||||||
router?: TopicMessageRouter
|
router?: TopicMessageRouter
|
||||||
|
topicProcessor?: import('./topic-processor').TopicProcessor
|
||||||
|
contextCache?: import('./household-context-cache').HouseholdContextCache
|
||||||
memoryStore?: AssistantConversationMemoryStore
|
memoryStore?: AssistantConversationMemoryStore
|
||||||
historyRepository?: TopicMessageHistoryRepository
|
historyRepository?: TopicMessageHistoryRepository
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
@@ -632,65 +547,154 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
pending?.action === PAYMENT_TOPIC_CONFIRMATION_ACTION
|
pending?.action === PAYMENT_TOPIC_CONFIRMATION_ACTION
|
||||||
? parsePaymentTopicConfirmationPayload(pending.payload)
|
? parsePaymentTopicConfirmationPayload(pending.payload)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Load household context (cached)
|
||||||
|
const householdContext = options.contextCache
|
||||||
|
? await options.contextCache.get(record.householdId, async () => {
|
||||||
|
const billingSettings =
|
||||||
|
await householdConfigurationRepository.getHouseholdBillingSettings(record.householdId)
|
||||||
const assistantConfig = await resolveAssistantConfig(
|
const assistantConfig = await resolveAssistantConfig(
|
||||||
householdConfigurationRepository,
|
householdConfigurationRepository,
|
||||||
record.householdId
|
record.householdId
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
householdContext: assistantConfig.assistantContext,
|
||||||
|
assistantTone: assistantConfig.assistantTone,
|
||||||
|
defaultCurrency: billingSettings.settlementCurrency,
|
||||||
|
locale: (await resolveTopicLocale(ctx, householdConfigurationRepository)) as
|
||||||
|
| 'en'
|
||||||
|
| 'ru',
|
||||||
|
cachedAt: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
householdContext: null as string | null,
|
||||||
|
assistantTone: null as string | null,
|
||||||
|
defaultCurrency: 'GEL' as const,
|
||||||
|
locale: 'en' as const,
|
||||||
|
cachedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
const activeWorkflow =
|
const activeWorkflow =
|
||||||
clarificationPayload && clarificationPayload.threadId === record.threadId
|
clarificationPayload && clarificationPayload.threadId === record.threadId
|
||||||
? 'payment_clarification'
|
? 'payment_clarification'
|
||||||
: confirmationPayload && confirmationPayload.telegramThreadId === record.threadId
|
: confirmationPayload && confirmationPayload.telegramThreadId === record.threadId
|
||||||
? 'payment_confirmation'
|
? 'payment_confirmation'
|
||||||
: null
|
: null
|
||||||
const route =
|
|
||||||
getCachedTopicMessageRoute(ctx, 'payments') ??
|
|
||||||
(await routePaymentTopicMessage({
|
|
||||||
record,
|
|
||||||
locale,
|
|
||||||
topicRole: 'payments',
|
|
||||||
isExplicitMention: stripExplicitBotMention(ctx) !== null,
|
|
||||||
isReplyToBot: isReplyToBotMessage(ctx),
|
|
||||||
activeWorkflow,
|
|
||||||
assistantContext: assistantConfig.assistantContext,
|
|
||||||
assistantTone: assistantConfig.assistantTone,
|
|
||||||
memoryStore: options.memoryStore,
|
|
||||||
historyRepository: options.historyRepository,
|
|
||||||
router: options.router
|
|
||||||
}))
|
|
||||||
cacheTopicMessageRoute(ctx, 'payments', route)
|
|
||||||
|
|
||||||
if (route.route === 'silent') {
|
// Use topic processor if available
|
||||||
|
if (options.topicProcessor) {
|
||||||
|
const { buildConversationContext } = await import('./conversation-orchestrator')
|
||||||
|
const { stripExplicitBotMention } = await import('./telegram-mentions')
|
||||||
|
|
||||||
|
const conversationContext = await buildConversationContext({
|
||||||
|
repository: options.historyRepository,
|
||||||
|
householdId: record.householdId,
|
||||||
|
telegramChatId: record.chatId,
|
||||||
|
telegramThreadId: record.threadId,
|
||||||
|
telegramUserId: record.senderTelegramUserId,
|
||||||
|
topicRole: 'payments',
|
||||||
|
activeWorkflow,
|
||||||
|
messageText: record.rawText,
|
||||||
|
explicitMention: stripExplicitBotMention(ctx) !== null,
|
||||||
|
replyToBot: isReplyToBotMessage(ctx),
|
||||||
|
directBotAddress: false,
|
||||||
|
memoryStore: options.memoryStore ?? {
|
||||||
|
get() {
|
||||||
|
return { summary: null, turns: [] }
|
||||||
|
},
|
||||||
|
appendTurn() {
|
||||||
|
return { summary: null, turns: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const processorResult = await options.topicProcessor({
|
||||||
|
locale: locale === 'ru' ? 'ru' : 'en',
|
||||||
|
topicRole: 'payments',
|
||||||
|
messageText: combinedText,
|
||||||
|
isExplicitMention: conversationContext.explicitMention,
|
||||||
|
isReplyToBot: conversationContext.replyToBot,
|
||||||
|
activeWorkflow,
|
||||||
|
defaultCurrency: householdContext.defaultCurrency,
|
||||||
|
householdContext: householdContext.householdContext,
|
||||||
|
assistantTone: householdContext.assistantTone,
|
||||||
|
householdMembers: [],
|
||||||
|
senderMemberId: null,
|
||||||
|
recentThreadMessages: conversationContext.recentThreadMessages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
speaker: m.speaker,
|
||||||
|
text: m.text
|
||||||
|
})),
|
||||||
|
recentChatMessages: conversationContext.recentSessionMessages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
speaker: m.speaker,
|
||||||
|
text: m.text
|
||||||
|
})),
|
||||||
|
recentTurns: conversationContext.recentTurns,
|
||||||
|
engagementAssessment: conversationContext.engagement
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle processor failure
|
||||||
|
if (!processorResult) {
|
||||||
|
const { botSleepsMessage } = await import('./topic-processor')
|
||||||
|
await replyToPaymentMessage(
|
||||||
|
ctx,
|
||||||
|
botSleepsMessage(locale === 'ru' ? 'ru' : 'en'),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different routes
|
||||||
|
switch (processorResult.route) {
|
||||||
|
case 'silent': {
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.shouldClearWorkflow && activeWorkflow !== null) {
|
case 'chat_reply': {
|
||||||
await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId)
|
await replyToPaymentMessage(ctx, processorResult.replyText, undefined, {
|
||||||
}
|
|
||||||
|
|
||||||
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
|
|
||||||
if (route.replyText) {
|
|
||||||
await replyToPaymentMessage(ctx, route.replyText, undefined, {
|
|
||||||
repository: options.historyRepository,
|
repository: options.historyRepository,
|
||||||
record
|
record
|
||||||
})
|
})
|
||||||
appendConversation(options.memoryStore, record, record.rawText, route.replyText)
|
appendConversation(
|
||||||
|
options.memoryStore,
|
||||||
|
record,
|
||||||
|
record.rawText,
|
||||||
|
processorResult.replyText
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dismiss_workflow': {
|
||||||
|
if (activeWorkflow !== null) {
|
||||||
|
await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId)
|
||||||
|
}
|
||||||
|
if (processorResult.replyText) {
|
||||||
|
await replyToPaymentMessage(ctx, processorResult.replyText, undefined, {
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
})
|
||||||
|
appendConversation(
|
||||||
|
options.memoryStore,
|
||||||
|
record,
|
||||||
|
record.rawText,
|
||||||
|
processorResult.replyText
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.route === 'topic_helper') {
|
case 'topic_helper': {
|
||||||
if (
|
|
||||||
route.reason === 'context_reference' ||
|
|
||||||
route.reason === 'engaged_context' ||
|
|
||||||
route.reason === 'addressed'
|
|
||||||
) {
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const financeService = financeServiceForHousehold(record.householdId)
|
const financeService = financeServiceForHousehold(record.householdId)
|
||||||
const member = await financeService.getMemberByTelegramUserId(record.senderTelegramUserId)
|
const member = await financeService.getMemberByTelegramUserId(
|
||||||
|
record.senderTelegramUserId
|
||||||
|
)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
@@ -718,37 +722,57 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.route !== 'payment_candidate' && route.route !== 'payment_followup') {
|
case 'payment_clarification': {
|
||||||
await next()
|
await promptRepository.upsertPendingAction({
|
||||||
|
telegramUserId: record.senderTelegramUserId,
|
||||||
|
telegramChatId: record.chatId,
|
||||||
|
action: PAYMENT_TOPIC_CLARIFICATION_ACTION,
|
||||||
|
payload: {
|
||||||
|
threadId: record.threadId,
|
||||||
|
rawText: combinedText
|
||||||
|
},
|
||||||
|
expiresAt: nowInstant().add({ milliseconds: PAYMENT_TOPIC_ACTION_TTL_MS })
|
||||||
|
})
|
||||||
|
|
||||||
|
await replyToPaymentMessage(ctx, processorResult.clarificationQuestion, undefined, {
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
})
|
||||||
|
appendConversation(
|
||||||
|
options.memoryStore,
|
||||||
|
record,
|
||||||
|
record.rawText,
|
||||||
|
processorResult.clarificationQuestion
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'payment': {
|
||||||
const t = getBotTranslations(locale).payments
|
const t = getBotTranslations(locale).payments
|
||||||
const financeService = financeServiceForHousehold(record.householdId)
|
const financeService = financeServiceForHousehold(record.householdId)
|
||||||
const member = await financeService.getMemberByTelegramUserId(record.senderTelegramUserId)
|
const member = await financeService.getMemberByTelegramUserId(
|
||||||
|
record.senderTelegramUserId
|
||||||
|
)
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create payment proposal using the parsed data from topic processor
|
||||||
|
const amountMajor = Money.fromMinor(
|
||||||
|
BigInt(processorResult.amountMinor),
|
||||||
|
processorResult.currency
|
||||||
|
).toMajorString()
|
||||||
const proposal = await maybeCreatePaymentProposal({
|
const proposal = await maybeCreatePaymentProposal({
|
||||||
rawText: combinedText,
|
rawText: `paid ${processorResult.kind} ${amountMajor} ${processorResult.currency}`,
|
||||||
householdId: record.householdId,
|
householdId: record.householdId,
|
||||||
memberId: member.id,
|
memberId: member.id,
|
||||||
financeService,
|
financeService,
|
||||||
householdConfigurationRepository
|
householdConfigurationRepository
|
||||||
})
|
})
|
||||||
|
|
||||||
if (proposal.status === 'no_intent') {
|
if (proposal.status === 'no_intent' || proposal.status === 'clarification') {
|
||||||
if (route.route === 'payment_followup') {
|
|
||||||
await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId)
|
|
||||||
}
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposal.status === 'clarification') {
|
|
||||||
await promptRepository.upsertPendingAction({
|
await promptRepository.upsertPendingAction({
|
||||||
telegramUserId: record.senderTelegramUserId,
|
telegramUserId: record.senderTelegramUserId,
|
||||||
telegramChatId: record.chatId,
|
telegramChatId: record.chatId,
|
||||||
@@ -822,6 +846,22 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
)
|
)
|
||||||
appendConversation(options.memoryStore, record, record.rawText, proposalText)
|
appendConversation(options.memoryStore, record, record.rawText, proposalText)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No topic processor available - bot sleeps
|
||||||
|
const { botSleepsMessage } = await import('./topic-processor')
|
||||||
|
await replyToPaymentMessage(ctx, botSleepsMessage(locale === 'ru' ? 'ru' : 'en'), undefined, {
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -512,6 +512,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -605,6 +608,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -670,6 +676,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -757,6 +766,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -866,6 +878,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -942,6 +957,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1037,6 +1055,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1125,6 +1146,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1182,6 +1206,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1224,6 +1251,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1271,6 +1301,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1325,6 +1358,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1391,6 +1427,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1457,6 +1496,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1516,6 +1558,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1581,6 +1626,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1655,6 +1703,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1712,6 +1763,9 @@ Confirm or cancel below.`
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1814,6 +1868,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1865,6 +1922,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1942,6 +2002,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -1992,6 +2055,9 @@ Confirm or cancel below.`,
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -2051,17 +2117,9 @@ Confirm or cancel below.`,
|
|||||||
repository,
|
repository,
|
||||||
{
|
{
|
||||||
historyRepository,
|
historyRepository,
|
||||||
router: async (input) => {
|
topicProcessor: async (input) => {
|
||||||
if (input.messageText.includes('картошки')) {
|
if (input.messageText.includes('картошки')) {
|
||||||
return {
|
return { route: 'silent', reason: 'planning' }
|
||||||
route: 'silent',
|
|
||||||
replyText: null,
|
|
||||||
helperKind: null,
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 90,
|
|
||||||
reason: 'planning'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recentTurnTexts = input.recentThreadMessages?.map((turn) => turn.text) ?? []
|
recentTurnTexts = input.recentThreadMessages?.map((turn) => turn.text) ?? []
|
||||||
@@ -2069,10 +2127,6 @@ Confirm or cancel below.`,
|
|||||||
return {
|
return {
|
||||||
route: 'chat_reply',
|
route: 'chat_reply',
|
||||||
replyText: 'No leaked context here.',
|
replyText: 'No leaked context here.',
|
||||||
helperKind: 'assistant',
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 91,
|
|
||||||
reason: 'thread_scoped'
|
reason: 'thread_scoped'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2136,6 +2190,9 @@ Confirm or cancel below.`,
|
|||||||
participants: participants()
|
participants: participants()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -2195,6 +2252,9 @@ Participants:
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -2258,6 +2318,9 @@ Participants:
|
|||||||
participants: participants()
|
participants: participants()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
@@ -2311,6 +2374,9 @@ Participants:
|
|||||||
async confirm() {
|
async confirm() {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
},
|
},
|
||||||
|
async saveWithInterpretation() {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
return {
|
return {
|
||||||
status: 'cancelled' as const,
|
status: 'cancelled' as const,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type TopicMessageRouter,
|
type TopicMessageRouter,
|
||||||
type TopicMessageRoutingResult
|
type TopicMessageRoutingResult
|
||||||
} from './topic-message-router'
|
} from './topic-message-router'
|
||||||
|
import { asOptionalBigInt } from './topic-processor'
|
||||||
import {
|
import {
|
||||||
persistTopicHistoryMessage,
|
persistTopicHistoryMessage,
|
||||||
telegramMessageIdFromMessage,
|
telegramMessageIdFromMessage,
|
||||||
@@ -210,6 +211,9 @@ export type PurchaseProposalAmountCorrectionResult =
|
|||||||
export interface PurchaseMessageIngestionRepository {
|
export interface PurchaseMessageIngestionRepository {
|
||||||
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
||||||
clearClarificationContext?(record: PurchaseTopicRecord): Promise<void>
|
clearClarificationContext?(record: PurchaseTopicRecord): Promise<void>
|
||||||
|
/**
|
||||||
|
* @deprecated Use saveWithInterpretation instead. This method will be removed.
|
||||||
|
*/
|
||||||
save(
|
save(
|
||||||
record: PurchaseTopicRecord,
|
record: PurchaseTopicRecord,
|
||||||
interpreter?: PurchaseMessageInterpreter,
|
interpreter?: PurchaseMessageInterpreter,
|
||||||
@@ -219,6 +223,10 @@ export interface PurchaseMessageIngestionRepository {
|
|||||||
assistantTone?: string | null
|
assistantTone?: string | null
|
||||||
}
|
}
|
||||||
): Promise<PurchaseMessageIngestionResult>
|
): Promise<PurchaseMessageIngestionResult>
|
||||||
|
saveWithInterpretation(
|
||||||
|
record: PurchaseTopicRecord,
|
||||||
|
interpretation: PurchaseInterpretation
|
||||||
|
): Promise<PurchaseMessageIngestionResult>
|
||||||
confirm(
|
confirm(
|
||||||
purchaseMessageId: string,
|
purchaseMessageId: string,
|
||||||
actorTelegramUserId: string
|
actorTelegramUserId: string
|
||||||
@@ -374,6 +382,37 @@ function normalizeInterpretation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toPurchaseInterpretation(
|
||||||
|
result: import('./topic-processor').TopicProcessorPurchaseResult
|
||||||
|
): PurchaseInterpretation {
|
||||||
|
return {
|
||||||
|
decision: 'purchase',
|
||||||
|
amountMinor: asOptionalBigInt(result.amountMinor),
|
||||||
|
currency: result.currency,
|
||||||
|
itemDescription: result.itemDescription,
|
||||||
|
amountSource: result.amountSource,
|
||||||
|
calculationExplanation: result.calculationExplanation,
|
||||||
|
participantMemberIds: result.participantMemberIds,
|
||||||
|
confidence: result.confidence,
|
||||||
|
parserMode: 'llm',
|
||||||
|
clarificationQuestion: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPurchaseClarificationInterpretation(
|
||||||
|
result: import('./topic-processor').TopicProcessorClarificationResult
|
||||||
|
): PurchaseInterpretation {
|
||||||
|
return {
|
||||||
|
decision: 'clarification',
|
||||||
|
amountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
itemDescription: null,
|
||||||
|
confidence: 0,
|
||||||
|
parserMode: 'llm',
|
||||||
|
clarificationQuestion: result.clarificationQuestion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function needsReviewAsInt(value: boolean): number {
|
function needsReviewAsInt(value: boolean): number {
|
||||||
return value ? 1 : 0
|
return value ? 1 : 0
|
||||||
}
|
}
|
||||||
@@ -1206,6 +1245,119 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async saveWithInterpretation(record, interpretation) {
|
||||||
|
const matchedMember = await db
|
||||||
|
.select({ id: schema.members.id })
|
||||||
|
.from(schema.members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.members.householdId, record.householdId),
|
||||||
|
eq(schema.members.telegramUserId, record.senderTelegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const senderMemberId = matchedMember[0]?.id ?? null
|
||||||
|
|
||||||
|
const decision = normalizeInterpretation(interpretation, null)
|
||||||
|
|
||||||
|
const inserted = await db
|
||||||
|
.insert(schema.purchaseMessages)
|
||||||
|
.values({
|
||||||
|
householdId: record.householdId,
|
||||||
|
senderMemberId,
|
||||||
|
senderTelegramUserId: record.senderTelegramUserId,
|
||||||
|
senderDisplayName: record.senderDisplayName,
|
||||||
|
rawText: record.rawText,
|
||||||
|
telegramChatId: record.chatId,
|
||||||
|
telegramMessageId: record.messageId,
|
||||||
|
telegramThreadId: record.threadId,
|
||||||
|
telegramUpdateId: String(record.updateId),
|
||||||
|
messageSentAt: instantToDate(record.messageSentAt),
|
||||||
|
parsedAmountMinor: decision.parsedAmountMinor,
|
||||||
|
parsedCurrency: decision.parsedCurrency,
|
||||||
|
parsedItemDescription: decision.parsedItemDescription,
|
||||||
|
parserMode: decision.parserMode,
|
||||||
|
parserConfidence: decision.parserConfidence,
|
||||||
|
needsReview: needsReviewAsInt(decision.needsReview),
|
||||||
|
parserError: decision.parserError,
|
||||||
|
processingStatus: decision.status
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [
|
||||||
|
schema.purchaseMessages.householdId,
|
||||||
|
schema.purchaseMessages.telegramChatId,
|
||||||
|
schema.purchaseMessages.telegramMessageId
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.returning({ id: schema.purchaseMessages.id })
|
||||||
|
|
||||||
|
const insertedRow = inserted[0]
|
||||||
|
if (!insertedRow) {
|
||||||
|
return {
|
||||||
|
status: 'duplicate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (decision.status) {
|
||||||
|
case 'ignored_not_purchase':
|
||||||
|
return {
|
||||||
|
status: 'ignored_not_purchase',
|
||||||
|
purchaseMessageId: insertedRow.id
|
||||||
|
}
|
||||||
|
case 'clarification_needed':
|
||||||
|
return {
|
||||||
|
status: 'clarification_needed',
|
||||||
|
purchaseMessageId: insertedRow.id,
|
||||||
|
clarificationQuestion: decision.clarificationQuestion,
|
||||||
|
parsedAmountMinor: decision.parsedAmountMinor,
|
||||||
|
parsedCurrency: decision.parsedCurrency,
|
||||||
|
parsedItemDescription: decision.parsedItemDescription,
|
||||||
|
amountSource: decision.amountSource,
|
||||||
|
calculationExplanation: decision.calculationExplanation,
|
||||||
|
parserConfidence: decision.parserConfidence,
|
||||||
|
parserMode: decision.parserMode
|
||||||
|
}
|
||||||
|
case 'pending_confirmation': {
|
||||||
|
const participants = await defaultProposalParticipants({
|
||||||
|
householdId: record.householdId,
|
||||||
|
senderTelegramUserId: record.senderTelegramUserId,
|
||||||
|
senderMemberId,
|
||||||
|
messageSentAt: record.messageSentAt,
|
||||||
|
explicitParticipantMemberIds: decision.participantMemberIds
|
||||||
|
})
|
||||||
|
|
||||||
|
if (participants.length > 0) {
|
||||||
|
await db.insert(schema.purchaseMessageParticipants).values(
|
||||||
|
participants.map((participant) => ({
|
||||||
|
purchaseMessageId: insertedRow.id,
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participantIncludedAsInt(participant.included)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
purchaseMessageId: insertedRow.id,
|
||||||
|
parsedAmountMinor: decision.parsedAmountMinor!,
|
||||||
|
parsedCurrency: decision.parsedCurrency!,
|
||||||
|
parsedItemDescription: decision.parsedItemDescription!,
|
||||||
|
amountSource: decision.amountSource,
|
||||||
|
calculationExplanation: decision.calculationExplanation,
|
||||||
|
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
|
||||||
|
parserMode: decision.parserMode ?? 'llm',
|
||||||
|
participants: toProposalParticipants(await getStoredParticipants(insertedRow.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'parse_failed':
|
||||||
|
return {
|
||||||
|
status: 'parse_failed',
|
||||||
|
purchaseMessageId: insertedRow.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async confirm(purchaseMessageId, actorTelegramUserId) {
|
async confirm(purchaseMessageId, actorTelegramUserId) {
|
||||||
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'confirmed')
|
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'confirmed')
|
||||||
},
|
},
|
||||||
@@ -2194,6 +2346,8 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
options: {
|
options: {
|
||||||
interpreter?: PurchaseMessageInterpreter
|
interpreter?: PurchaseMessageInterpreter
|
||||||
router?: TopicMessageRouter
|
router?: TopicMessageRouter
|
||||||
|
topicProcessor?: import('./topic-processor').TopicProcessor
|
||||||
|
contextCache?: import('./household-context-cache').HouseholdContextCache
|
||||||
memoryStore?: AssistantConversationMemoryStore
|
memoryStore?: AssistantConversationMemoryStore
|
||||||
historyRepository?: TopicMessageHistoryRepository
|
historyRepository?: TopicMessageHistoryRepository
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
@@ -2232,6 +2386,9 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
let typingIndicator: ReturnType<typeof startTypingIndicator> | null = null
|
let typingIndicator: ReturnType<typeof startTypingIndicator> | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Load household context (cached)
|
||||||
|
const householdContext = options.contextCache
|
||||||
|
? await options.contextCache.get(record.householdId, async () => {
|
||||||
const [billingSettings, assistantConfig] = await Promise.all([
|
const [billingSettings, assistantConfig] = await Promise.all([
|
||||||
householdConfigurationRepository.getHouseholdBillingSettings(record.householdId),
|
householdConfigurationRepository.getHouseholdBillingSettings(record.householdId),
|
||||||
resolveAssistantConfig(householdConfigurationRepository, record.householdId)
|
resolveAssistantConfig(householdConfigurationRepository, record.householdId)
|
||||||
@@ -2240,82 +2397,176 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
householdConfigurationRepository,
|
householdConfigurationRepository,
|
||||||
record.householdId
|
record.householdId
|
||||||
)
|
)
|
||||||
const route =
|
return {
|
||||||
getCachedTopicMessageRoute(ctx, 'purchase') ??
|
householdContext: assistantConfig.assistantContext,
|
||||||
(await routePurchaseTopicMessage({
|
assistantTone: assistantConfig.assistantTone,
|
||||||
ctx,
|
defaultCurrency: billingSettings.settlementCurrency,
|
||||||
record,
|
|
||||||
locale,
|
locale,
|
||||||
repository,
|
cachedAt: Date.now()
|
||||||
router: options.router,
|
}
|
||||||
memoryStore: options.memoryStore,
|
})
|
||||||
historyRepository: options.historyRepository,
|
: {
|
||||||
assistantContext: assistantConfig.assistantContext,
|
householdContext: null as string | null,
|
||||||
assistantTone: assistantConfig.assistantTone
|
assistantTone: null as string | null,
|
||||||
}))
|
defaultCurrency: 'GEL' as const,
|
||||||
cacheTopicMessageRoute(ctx, 'purchase', route)
|
locale: 'en' as BotLocale,
|
||||||
|
cachedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build conversation context
|
||||||
|
const activeWorkflow = (await repository.hasClarificationContext(record))
|
||||||
|
? 'purchase_clarification'
|
||||||
|
: null
|
||||||
|
|
||||||
|
const conversationContext = await buildConversationContext({
|
||||||
|
repository: options.historyRepository,
|
||||||
|
householdId: record.householdId,
|
||||||
|
telegramChatId: record.chatId,
|
||||||
|
telegramThreadId: record.threadId,
|
||||||
|
telegramUserId: record.senderTelegramUserId,
|
||||||
|
topicRole: 'purchase',
|
||||||
|
activeWorkflow,
|
||||||
|
messageText: record.rawText,
|
||||||
|
explicitMention: stripExplicitBotMention(ctx) !== null,
|
||||||
|
replyToBot: isReplyToCurrentBot(ctx),
|
||||||
|
directBotAddress: false,
|
||||||
|
memoryStore: options.memoryStore ?? {
|
||||||
|
get() {
|
||||||
|
return { summary: null, turns: [] }
|
||||||
|
},
|
||||||
|
appendTurn() {
|
||||||
|
return { summary: null, turns: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get household members for the processor
|
||||||
|
const householdMembers = await (async () => {
|
||||||
|
if (!options.topicProcessor) return []
|
||||||
|
// This will be loaded from DB in the actual implementation
|
||||||
|
// For now, we return empty array - the processor will work without it
|
||||||
|
return []
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Use topic processor if available, fall back to legacy router
|
||||||
|
if (options.topicProcessor) {
|
||||||
|
const processorResult = await options.topicProcessor({
|
||||||
|
locale: householdContext.locale === 'ru' ? 'ru' : 'en',
|
||||||
|
topicRole: 'purchase',
|
||||||
|
messageText: record.rawText,
|
||||||
|
isExplicitMention: conversationContext.explicitMention,
|
||||||
|
isReplyToBot: conversationContext.replyToBot,
|
||||||
|
activeWorkflow,
|
||||||
|
defaultCurrency: householdContext.defaultCurrency,
|
||||||
|
householdContext: householdContext.householdContext,
|
||||||
|
assistantTone: householdContext.assistantTone,
|
||||||
|
householdMembers,
|
||||||
|
senderMemberId: null, // Will be resolved in saveWithInterpretation
|
||||||
|
recentThreadMessages: conversationContext.recentThreadMessages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
speaker: m.speaker,
|
||||||
|
text: m.text
|
||||||
|
})),
|
||||||
|
recentChatMessages: conversationContext.recentSessionMessages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
speaker: m.speaker,
|
||||||
|
text: m.text
|
||||||
|
})),
|
||||||
|
recentTurns: conversationContext.recentTurns,
|
||||||
|
engagementAssessment: conversationContext.engagement
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle processor failure - fun "bot sleeps" message
|
||||||
|
if (!processorResult) {
|
||||||
|
const { botSleepsMessage } = await import('./topic-processor')
|
||||||
|
await replyToPurchaseMessage(
|
||||||
|
ctx,
|
||||||
|
botSleepsMessage(householdContext.locale === 'ru' ? 'ru' : 'en'),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (route.route === 'silent') {
|
|
||||||
rememberUserTurn(options.memoryStore, record)
|
rememberUserTurn(options.memoryStore, record)
|
||||||
|
|
||||||
|
// Handle different routes
|
||||||
|
switch (processorResult.route) {
|
||||||
|
case 'silent': {
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.shouldClearWorkflow) {
|
case 'chat_reply': {
|
||||||
await repository.clearClarificationContext?.(record)
|
await replyToPurchaseMessage(ctx, processorResult.replyText, undefined, {
|
||||||
}
|
|
||||||
|
|
||||||
if (route.route === 'chat_reply' || route.route === 'dismiss_workflow') {
|
|
||||||
rememberUserTurn(options.memoryStore, record)
|
|
||||||
if (route.replyText) {
|
|
||||||
await replyToPurchaseMessage(ctx, route.replyText, undefined, {
|
|
||||||
repository: options.historyRepository,
|
repository: options.historyRepository,
|
||||||
record
|
record
|
||||||
})
|
})
|
||||||
rememberAssistantTurn(options.memoryStore, record, route.replyText)
|
rememberAssistantTurn(options.memoryStore, record, processorResult.replyText)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.route === 'topic_helper') {
|
case 'topic_helper': {
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.route !== 'purchase_candidate' && route.route !== 'purchase_followup') {
|
case 'dismiss_workflow': {
|
||||||
rememberUserTurn(options.memoryStore, record)
|
|
||||||
await next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rememberUserTurn(options.memoryStore, record)
|
|
||||||
typingIndicator =
|
|
||||||
options.interpreter && route.shouldStartTyping ? startTypingIndicator(ctx) : null
|
|
||||||
const pendingReply =
|
|
||||||
options.interpreter && shouldShowProcessingReply(ctx, record, route)
|
|
||||||
? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing)
|
|
||||||
: null
|
|
||||||
const result = await repository.save(
|
|
||||||
record,
|
|
||||||
options.interpreter,
|
|
||||||
billingSettings.settlementCurrency,
|
|
||||||
{
|
|
||||||
householdContext: assistantConfig.assistantContext,
|
|
||||||
assistantTone: assistantConfig.assistantTone
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (result.status === 'ignored_not_purchase') {
|
|
||||||
if (route.route === 'purchase_followup') {
|
|
||||||
await repository.clearClarificationContext?.(record)
|
await repository.clearClarificationContext?.(record)
|
||||||
|
if (processorResult.replyText) {
|
||||||
|
await replyToPurchaseMessage(ctx, processorResult.replyText, undefined, {
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
})
|
||||||
|
rememberAssistantTurn(options.memoryStore, record, processorResult.replyText)
|
||||||
}
|
}
|
||||||
return await next()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'purchase_clarification': {
|
||||||
|
typingIndicator = startTypingIndicator(ctx)
|
||||||
|
const interpretation = toPurchaseClarificationInterpretation(processorResult)
|
||||||
|
const result = await repository.saveWithInterpretation(record, interpretation)
|
||||||
|
await handlePurchaseMessageResult(
|
||||||
|
ctx,
|
||||||
|
record,
|
||||||
|
result,
|
||||||
|
householdContext.locale,
|
||||||
|
options.logger,
|
||||||
|
null,
|
||||||
|
options.historyRepository
|
||||||
|
)
|
||||||
|
rememberAssistantTurn(
|
||||||
|
options.memoryStore,
|
||||||
|
record,
|
||||||
|
buildPurchaseAcknowledgement(result, householdContext.locale)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'purchase': {
|
||||||
|
typingIndicator = startTypingIndicator(ctx)
|
||||||
|
const interpretation = toPurchaseInterpretation(processorResult)
|
||||||
|
const pendingReply = await sendPurchaseProcessingReply(
|
||||||
|
ctx,
|
||||||
|
getBotTranslations(householdContext.locale).purchase.processing
|
||||||
|
)
|
||||||
|
const result = await repository.saveWithInterpretation(record, interpretation)
|
||||||
|
|
||||||
|
if (result.status === 'ignored_not_purchase') {
|
||||||
|
await repository.clearClarificationContext?.(record)
|
||||||
|
await next()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await handlePurchaseMessageResult(
|
await handlePurchaseMessageResult(
|
||||||
ctx,
|
ctx,
|
||||||
record,
|
record,
|
||||||
result,
|
result,
|
||||||
locale,
|
householdContext.locale,
|
||||||
options.logger,
|
options.logger,
|
||||||
pendingReply,
|
pendingReply,
|
||||||
options.historyRepository
|
options.historyRepository
|
||||||
@@ -2323,7 +2574,28 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
rememberAssistantTurn(
|
rememberAssistantTurn(
|
||||||
options.memoryStore,
|
options.memoryStore,
|
||||||
record,
|
record,
|
||||||
buildPurchaseAcknowledgement(result, locale)
|
buildPurchaseAcknowledgement(result, householdContext.locale)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No topic processor available - bot sleeps
|
||||||
|
const { botSleepsMessage } = await import('./topic-processor')
|
||||||
|
await replyToPurchaseMessage(
|
||||||
|
ctx,
|
||||||
|
botSleepsMessage(householdContext.locale === 'ru' ? 'ru' : 'en'),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
repository: options.historyRepository,
|
||||||
|
record
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
|
|||||||
@@ -1,174 +1,136 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { createOpenAiTopicMessageRouter } from './topic-message-router'
|
import { fallbackTopicMessageRoute } from './topic-message-router'
|
||||||
|
|
||||||
function successfulResponse(payload: unknown): Response {
|
describe('fallbackTopicMessageRoute', () => {
|
||||||
return new Response(JSON.stringify(payload), {
|
test('returns silent for empty messages', () => {
|
||||||
status: 200,
|
const route = fallbackTopicMessageRoute({
|
||||||
headers: {
|
locale: 'en',
|
||||||
'content-type': 'application/json'
|
topicRole: 'purchase',
|
||||||
|
messageText: '',
|
||||||
|
isExplicitMention: false,
|
||||||
|
isReplyToBot: false,
|
||||||
|
activeWorkflow: null
|
||||||
|
})
|
||||||
|
expect(route.route).toBe('silent')
|
||||||
|
expect(route.reason).toBe('empty')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns purchase_followup for active purchase clarification workflow', () => {
|
||||||
|
const route = fallbackTopicMessageRoute({
|
||||||
|
locale: 'en',
|
||||||
|
topicRole: 'purchase',
|
||||||
|
messageText: 'some message',
|
||||||
|
isExplicitMention: false,
|
||||||
|
isReplyToBot: false,
|
||||||
|
activeWorkflow: 'purchase_clarification'
|
||||||
|
})
|
||||||
|
expect(route.route).toBe('purchase_followup')
|
||||||
|
expect(route.helperKind).toBe('purchase')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns payment_followup for active payment clarification workflow', () => {
|
||||||
|
const route = fallbackTopicMessageRoute({
|
||||||
|
locale: 'en',
|
||||||
|
topicRole: 'payments',
|
||||||
|
messageText: 'some message',
|
||||||
|
isExplicitMention: false,
|
||||||
|
isReplyToBot: false,
|
||||||
|
activeWorkflow: 'payment_clarification'
|
||||||
|
})
|
||||||
|
expect(route.route).toBe('payment_followup')
|
||||||
|
expect(route.helperKind).toBe('payment')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns payment_followup for active payment confirmation workflow', () => {
|
||||||
|
const route = fallbackTopicMessageRoute({
|
||||||
|
locale: 'en',
|
||||||
|
topicRole: 'payments',
|
||||||
|
messageText: 'some message',
|
||||||
|
isExplicitMention: false,
|
||||||
|
isReplyToBot: false,
|
||||||
|
activeWorkflow: 'payment_confirmation'
|
||||||
|
})
|
||||||
|
expect(route.route).toBe('payment_followup')
|
||||||
|
expect(route.helperKind).toBe('payment')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns topic_helper for strong reference', () => {
|
||||||
|
const route = fallbackTopicMessageRoute({
|
||||||
|
locale: 'en',
|
||||||
|
topicRole: 'generic',
|
||||||
|
messageText: 'some message',
|
||||||
|
isExplicitMention: false,
|
||||||
|
isReplyToBot: false,
|
||||||
|
activeWorkflow: null,
|
||||||
|
engagementAssessment: {
|
||||||
|
engaged: true,
|
||||||
|
reason: 'strong_reference',
|
||||||
|
strongReference: true,
|
||||||
|
weakSessionActive: false,
|
||||||
|
hasOpenBotQuestion: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
expect(route.route).toBe('topic_helper')
|
||||||
|
expect(route.helperKind).toBe('assistant')
|
||||||
describe('createOpenAiTopicMessageRouter', () => {
|
|
||||||
test('does not override purchase routes for planning chatter', async () => {
|
|
||||||
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
|
|
||||||
expect(router).toBeDefined()
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
successfulResponse({
|
|
||||||
output_text: JSON.stringify({
|
|
||||||
route: 'purchase_candidate',
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'purchase',
|
|
||||||
shouldStartTyping: true,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 92,
|
|
||||||
reason: 'llm_purchase_guess'
|
|
||||||
})
|
})
|
||||||
})) as unknown as typeof fetch
|
|
||||||
|
|
||||||
try {
|
test('returns topic_helper for weak session', () => {
|
||||||
const route = await router!({
|
const route = fallbackTopicMessageRoute({
|
||||||
locale: 'ru',
|
locale: 'en',
|
||||||
topicRole: 'purchase',
|
topicRole: 'generic',
|
||||||
messageText: 'Я хочу рыбу. Завтра подумаю, примерно 20 лари.',
|
messageText: 'some message',
|
||||||
|
isExplicitMention: false,
|
||||||
|
isReplyToBot: false,
|
||||||
|
activeWorkflow: null,
|
||||||
|
engagementAssessment: {
|
||||||
|
engaged: true,
|
||||||
|
reason: 'weak_session',
|
||||||
|
strongReference: false,
|
||||||
|
weakSessionActive: true,
|
||||||
|
hasOpenBotQuestion: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(route.route).toBe('topic_helper')
|
||||||
|
expect(route.helperKind).toBe('assistant')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns topic_helper for explicit mention', () => {
|
||||||
|
const route = fallbackTopicMessageRoute({
|
||||||
|
locale: 'en',
|
||||||
|
topicRole: 'generic',
|
||||||
|
messageText: 'some message',
|
||||||
isExplicitMention: true,
|
isExplicitMention: true,
|
||||||
isReplyToBot: false,
|
isReplyToBot: false,
|
||||||
activeWorkflow: null
|
activeWorkflow: null
|
||||||
})
|
})
|
||||||
|
expect(route.route).toBe('topic_helper')
|
||||||
expect(route).toMatchObject({
|
expect(route.helperKind).toBe('assistant')
|
||||||
route: 'purchase_candidate',
|
|
||||||
helperKind: 'purchase',
|
|
||||||
shouldStartTyping: true,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
reason: 'llm_purchase_guess'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not override purchase followups for meta references', async () => {
|
test('returns topic_helper for reply to bot', () => {
|
||||||
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
|
const route = fallbackTopicMessageRoute({
|
||||||
expect(router).toBeDefined()
|
locale: 'en',
|
||||||
|
topicRole: 'generic',
|
||||||
const originalFetch = globalThis.fetch
|
messageText: 'some message',
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
successfulResponse({
|
|
||||||
output_text: JSON.stringify({
|
|
||||||
route: 'purchase_followup',
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'purchase',
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 89,
|
|
||||||
reason: 'llm_followup_guess'
|
|
||||||
})
|
|
||||||
})) as unknown as typeof fetch
|
|
||||||
|
|
||||||
try {
|
|
||||||
const route = await router!({
|
|
||||||
locale: 'ru',
|
|
||||||
topicRole: 'purchase',
|
|
||||||
messageText: 'Я уже сказал выше',
|
|
||||||
isExplicitMention: false,
|
isExplicitMention: false,
|
||||||
isReplyToBot: true,
|
isReplyToBot: true,
|
||||||
activeWorkflow: 'purchase_clarification'
|
activeWorkflow: null
|
||||||
|
})
|
||||||
|
expect(route.route).toBe('topic_helper')
|
||||||
|
expect(route.helperKind).toBe('assistant')
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(route).toMatchObject({
|
test('returns silent by default', () => {
|
||||||
route: 'purchase_followup',
|
const route = fallbackTopicMessageRoute({
|
||||||
helperKind: 'purchase',
|
locale: 'en',
|
||||||
shouldStartTyping: false,
|
topicRole: 'generic',
|
||||||
shouldClearWorkflow: false,
|
messageText: 'some message',
|
||||||
reason: 'llm_followup_guess'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('keeps payment followups when a context reference also includes payment details', async () => {
|
|
||||||
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
|
|
||||||
expect(router).toBeDefined()
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
successfulResponse({
|
|
||||||
output_text: JSON.stringify({
|
|
||||||
route: 'payment_followup',
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'payment',
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 90,
|
|
||||||
reason: 'llm_payment_followup'
|
|
||||||
})
|
|
||||||
})) as unknown as typeof fetch
|
|
||||||
|
|
||||||
try {
|
|
||||||
const route = await router!({
|
|
||||||
locale: 'ru',
|
|
||||||
topicRole: 'payments',
|
|
||||||
messageText: 'Я уже сказал выше, оплатил 100 лари',
|
|
||||||
isExplicitMention: false,
|
isExplicitMention: false,
|
||||||
isReplyToBot: true,
|
isReplyToBot: false,
|
||||||
activeWorkflow: 'payment_clarification'
|
activeWorkflow: null
|
||||||
})
|
})
|
||||||
|
expect(route.route).toBe('silent')
|
||||||
expect(route).toMatchObject({
|
expect(route.reason).toBe('quiet_default')
|
||||||
route: 'payment_followup',
|
|
||||||
helperKind: 'payment',
|
|
||||||
shouldStartTyping: false,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
reason: 'llm_payment_followup'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('keeps purchase followups for approximate clarification answers', async () => {
|
|
||||||
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
|
|
||||||
expect(router).toBeDefined()
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
successfulResponse({
|
|
||||||
output_text: JSON.stringify({
|
|
||||||
route: 'purchase_followup',
|
|
||||||
replyText: null,
|
|
||||||
helperKind: 'purchase',
|
|
||||||
shouldStartTyping: true,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
confidence: 86,
|
|
||||||
reason: 'llm_purchase_followup'
|
|
||||||
})
|
|
||||||
})) as unknown as typeof fetch
|
|
||||||
|
|
||||||
try {
|
|
||||||
const route = await router!({
|
|
||||||
locale: 'ru',
|
|
||||||
topicRole: 'purchase',
|
|
||||||
messageText: 'примерно 20 лари',
|
|
||||||
isExplicitMention: false,
|
|
||||||
isReplyToBot: true,
|
|
||||||
activeWorkflow: 'purchase_clarification'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(route).toMatchObject({
|
|
||||||
route: 'purchase_followup',
|
|
||||||
helperKind: 'purchase',
|
|
||||||
shouldStartTyping: true,
|
|
||||||
shouldClearWorkflow: false,
|
|
||||||
reason: 'llm_purchase_followup'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { Context } from 'grammy'
|
import type { Context } from 'grammy'
|
||||||
|
|
||||||
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
|
|
||||||
|
|
||||||
export type TopicMessageRole = 'generic' | 'purchase' | 'payments' | 'reminders' | 'feedback'
|
export type TopicMessageRole = 'generic' | 'purchase' | 'payments' | 'reminders' | 'feedback'
|
||||||
export type TopicWorkflowState =
|
export type TopicWorkflowState =
|
||||||
| 'purchase_clarification'
|
| 'purchase_clarification'
|
||||||
@@ -79,35 +77,6 @@ type ContextWithTopicMessageRouteCache = Context & {
|
|||||||
[topicMessageRouteCacheKey]?: TopicMessageRouteCacheEntry
|
[topicMessageRouteCacheKey]?: TopicMessageRouteCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRoute(value: string): TopicMessageRoute {
|
|
||||||
return value === 'chat_reply' ||
|
|
||||||
value === 'purchase_candidate' ||
|
|
||||||
value === 'purchase_followup' ||
|
|
||||||
value === 'payment_candidate' ||
|
|
||||||
value === 'payment_followup' ||
|
|
||||||
value === 'topic_helper' ||
|
|
||||||
value === 'dismiss_workflow'
|
|
||||||
? value
|
|
||||||
: 'silent'
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHelperKind(value: string | null): TopicMessageRoutingResult['helperKind'] {
|
|
||||||
return value === 'assistant' ||
|
|
||||||
value === 'purchase' ||
|
|
||||||
value === 'payment' ||
|
|
||||||
value === 'reminder'
|
|
||||||
? value
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfidence(value: number | null | undefined): number {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(0, Math.min(100, Math.round(value)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fallbackTopicMessageRoute(
|
export function fallbackTopicMessageRoute(
|
||||||
input: TopicMessageRoutingInput
|
input: TopicMessageRoutingInput
|
||||||
): TopicMessageRoutingResult {
|
): TopicMessageRoutingResult {
|
||||||
@@ -194,43 +163,6 @@ export function fallbackTopicMessageRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecentTurns(input: TopicMessageRoutingInput): string | null {
|
|
||||||
const recentTurns = input.recentTurns
|
|
||||||
?.slice(-4)
|
|
||||||
.map((turn) => `${turn.role}: ${turn.text.trim()}`)
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
return recentTurns && recentTurns.length > 0
|
|
||||||
? ['Recent conversation with this user in the household chat:', ...recentTurns].join('\n')
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRecentThreadMessages(input: TopicMessageRoutingInput): string | null {
|
|
||||||
const recentMessages = input.recentThreadMessages
|
|
||||||
?.slice(-8)
|
|
||||||
.map((message) => `${message.speaker} (${message.role}): ${message.text.trim()}`)
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
return recentMessages && recentMessages.length > 0
|
|
||||||
? ['Recent messages in this topic thread:', ...recentMessages].join('\n')
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRecentChatMessages(input: TopicMessageRoutingInput): string | null {
|
|
||||||
const recentMessages = input.recentChatMessages
|
|
||||||
?.slice(-12)
|
|
||||||
.map((message) =>
|
|
||||||
message.threadId
|
|
||||||
? `[thread ${message.threadId}] ${message.speaker} (${message.role}): ${message.text.trim()}`
|
|
||||||
: `${message.speaker} (${message.role}): ${message.text.trim()}`
|
|
||||||
)
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
return recentMessages && recentMessages.length > 0
|
|
||||||
? ['Recent related chat messages:', ...recentMessages].join('\n')
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cacheTopicMessageRoute(
|
export function cacheTopicMessageRoute(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
topicRole: CachedTopicMessageRole,
|
topicRole: CachedTopicMessageRole,
|
||||||
@@ -249,201 +181,3 @@ export function getCachedTopicMessageRoute(
|
|||||||
const cached = (ctx as ContextWithTopicMessageRouteCache)[topicMessageRouteCacheKey]
|
const cached = (ctx as ContextWithTopicMessageRouteCache)[topicMessageRouteCacheKey]
|
||||||
return cached?.topicRole === topicRole ? cached.route : null
|
return cached?.topicRole === topicRole ? cached.route : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOpenAiTopicMessageRouter(
|
|
||||||
apiKey: string | undefined,
|
|
||||||
model: string,
|
|
||||||
timeoutMs: number
|
|
||||||
): TopicMessageRouter | undefined {
|
|
||||||
if (!apiKey) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return async (input) => {
|
|
||||||
const abortController = new AbortController()
|
|
||||||
const timeout = setTimeout(() => abortController.abort(), timeoutMs)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://api.openai.com/v1/responses', {
|
|
||||||
method: 'POST',
|
|
||||||
signal: abortController.signal,
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${apiKey}`,
|
|
||||||
'content-type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
input: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: [
|
|
||||||
'You are a first-pass router for a household Telegram bot in a group chat topic.',
|
|
||||||
'Your job is to decide whether the bot should stay silent, send a short playful reply, continue a workflow, or invoke a heavier helper.',
|
|
||||||
'When engaged=yes OR explicit_mention=yes OR reply_to_bot=yes, you MUST respond - never use silent route.',
|
|
||||||
'Decide from context whether the user is actually addressing the bot, talking about the bot, or talking to another person.',
|
|
||||||
'Treat "stop", "leave me alone", "just thinking", "not a purchase", and similar messages as backoff or dismissal signals.',
|
|
||||||
'For a bare summon like "bot?", "pss bot", or "ты тут?", prefer a brief acknowledgment with chat_reply.',
|
|
||||||
'When the user directly addresses the bot with small talk, joking, or testing, prefer chat_reply with one short sentence.',
|
|
||||||
'Do not repeatedly end casual replies with "how can I help?" unless the user is clearly asking for assistance.',
|
|
||||||
'Use topic_helper only when the message is a real question or request that likely needs household knowledge or a topic-specific helper.',
|
|
||||||
'Use the recent conversation when writing replyText. Do not ignore the already-established subject.',
|
|
||||||
'The recent thread messages are more important than the per-user memory summary.',
|
|
||||||
'If the user asks what you think about a price or quantity, mention the actual item/price from context when possible.',
|
|
||||||
'Set shouldStartTyping to true only if the chosen route will likely trigger a slower helper or assistant call.',
|
|
||||||
'=== PURCHASE TOPIC RULES ===',
|
|
||||||
'Classify as purchase_candidate when ALL of:',
|
|
||||||
'- Contains completed purchase verb (купил, bought, ordered, picked up, spent, взял, заказал, потратил)',
|
|
||||||
'- Contains realistic household item (food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant)',
|
|
||||||
'- Contains amount that is realistic for household purchase (under 500 GEL/USD/EUR)',
|
|
||||||
'- NOT a fantastical/impossible item',
|
|
||||||
'Gifts for household members ARE shared purchases - classify as purchase_candidate.',
|
|
||||||
'Classify as chat_reply (NOT silent) with playful response when:',
|
|
||||||
'- Item is fantastical (car, plane, rocket, island, castle, yacht, apartment renovation >1000)',
|
|
||||||
'- Amount is excessively large (>500 GEL/USD/EUR)',
|
|
||||||
'- User explicitly says it is a joke, gift for non-household member, or personal expense',
|
|
||||||
'Examples of purchase_candidate: "купил бананов 10 лари", "bought groceries 50 gel", "взял такси 15 лари", "купил Диме игрушку 20 лари"',
|
|
||||||
'Examples of chat_reply: "купил машину", "купил квартиру", "купил самолет" (respond playfully: "Ого, записывай сам!" or similar)',
|
|
||||||
'Use purchase_followup only when there is active purchase clarification and the latest message looks like a real answer to it.',
|
|
||||||
'=== PAYMENT TOPIC RULES ===',
|
|
||||||
'Classify as payment_candidate when:',
|
|
||||||
'- Contains payment verb (оплатил, paid, заплатил) + rent/utilities/bills',
|
|
||||||
'- Amount is realistic (<500)',
|
|
||||||
'Classify as chat_reply with playful response for fantastical amounts (>500).',
|
|
||||||
'Use payment_followup only when there is active payment clarification/confirmation and the latest message looks like a real answer to it.',
|
|
||||||
'=== GENERAL ===',
|
|
||||||
'For absurd or playful messages, be light and short with chat_reply. Never loop or interrogate.',
|
|
||||||
input.assistantTone ? `Use this tone lightly: ${input.assistantTone}.` : null,
|
|
||||||
input.assistantContext
|
|
||||||
? `Household flavor context: ${input.assistantContext}`
|
|
||||||
: null,
|
|
||||||
'Return only JSON matching the schema.'
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
`User locale: ${input.locale}`,
|
|
||||||
`Topic role: ${input.topicRole}`,
|
|
||||||
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
|
|
||||||
`Reply to bot: ${input.isReplyToBot ? '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}`
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n\n')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
text: {
|
|
||||||
format: {
|
|
||||||
type: 'json_schema',
|
|
||||||
name: 'topic_message_route',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
route: {
|
|
||||||
type: 'string',
|
|
||||||
enum: [
|
|
||||||
'silent',
|
|
||||||
'chat_reply',
|
|
||||||
'purchase_candidate',
|
|
||||||
'purchase_followup',
|
|
||||||
'payment_candidate',
|
|
||||||
'payment_followup',
|
|
||||||
'topic_helper',
|
|
||||||
'dismiss_workflow'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
replyText: {
|
|
||||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
|
||||||
},
|
|
||||||
helperKind: {
|
|
||||||
anyOf: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
enum: ['assistant', 'purchase', 'payment', 'reminder']
|
|
||||||
},
|
|
||||||
{ type: 'null' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
shouldStartTyping: {
|
|
||||||
type: 'boolean'
|
|
||||||
},
|
|
||||||
shouldClearWorkflow: {
|
|
||||||
type: 'boolean'
|
|
||||||
},
|
|
||||||
confidence: {
|
|
||||||
type: 'number',
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 100
|
|
||||||
},
|
|
||||||
reason: {
|
|
||||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: [
|
|
||||||
'route',
|
|
||||||
'replyText',
|
|
||||||
'helperKind',
|
|
||||||
'shouldStartTyping',
|
|
||||||
'shouldClearWorkflow',
|
|
||||||
'confidence',
|
|
||||||
'reason'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return fallbackTopicMessageRoute(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as Record<string, unknown>
|
|
||||||
const text = extractOpenAiResponseText(payload)
|
|
||||||
const parsed = parseJsonFromResponseText(text ?? '')
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
return fallbackTopicMessageRoute(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedObject = parsed as Record<string, unknown>
|
|
||||||
|
|
||||||
const route = normalizeRoute(
|
|
||||||
typeof parsedObject.route === 'string' ? parsedObject.route : 'silent'
|
|
||||||
)
|
|
||||||
const replyText =
|
|
||||||
typeof parsedObject.replyText === 'string' && parsedObject.replyText.trim().length > 0
|
|
||||||
? parsedObject.replyText.trim()
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
|
||||||
route,
|
|
||||||
replyText,
|
|
||||||
helperKind:
|
|
||||||
typeof parsedObject.helperKind === 'string' || parsedObject.helperKind === null
|
|
||||||
? normalizeHelperKind(parsedObject.helperKind)
|
|
||||||
: null,
|
|
||||||
shouldStartTyping: parsedObject.shouldStartTyping === true,
|
|
||||||
shouldClearWorkflow: parsedObject.shouldClearWorkflow === true,
|
|
||||||
confidence: normalizeConfidence(
|
|
||||||
typeof parsedObject.confidence === 'number' ? parsedObject.confidence : null
|
|
||||||
),
|
|
||||||
reason: typeof parsedObject.reason === 'string' ? parsedObject.reason : null
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return fallbackTopicMessageRoute(input)
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
533
apps/bot/src/topic-processor.ts
Normal file
533
apps/bot/src/topic-processor.ts
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
|
||||||
|
import type { TopicWorkflowState } from './topic-message-router'
|
||||||
|
import type { EngagementAssessment } from './conversation-orchestrator'
|
||||||
|
|
||||||
|
export type TopicProcessorRoute =
|
||||||
|
| 'silent'
|
||||||
|
| 'chat_reply'
|
||||||
|
| 'purchase'
|
||||||
|
| 'purchase_clarification'
|
||||||
|
| 'payment'
|
||||||
|
| 'payment_clarification'
|
||||||
|
| 'topic_helper'
|
||||||
|
| 'dismiss_workflow'
|
||||||
|
|
||||||
|
export interface TopicProcessorPurchaseResult {
|
||||||
|
route: 'purchase'
|
||||||
|
amountMinor: string
|
||||||
|
currency: 'GEL' | 'USD'
|
||||||
|
itemDescription: string
|
||||||
|
amountSource: 'explicit' | 'calculated'
|
||||||
|
calculationExplanation: string | null
|
||||||
|
participantMemberIds: string[] | null
|
||||||
|
confidence: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorPaymentResult {
|
||||||
|
route: 'payment'
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
amountMinor: string
|
||||||
|
currency: 'GEL' | 'USD'
|
||||||
|
confidence: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorChatReplyResult {
|
||||||
|
route: 'chat_reply'
|
||||||
|
replyText: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorSilentResult {
|
||||||
|
route: 'silent'
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorClarificationResult {
|
||||||
|
route: 'purchase_clarification' | 'payment_clarification'
|
||||||
|
clarificationQuestion: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorTopicHelperResult {
|
||||||
|
route: 'topic_helper'
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorDismissWorkflowResult {
|
||||||
|
route: 'dismiss_workflow'
|
||||||
|
replyText: string | null
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopicProcessorResult =
|
||||||
|
| TopicProcessorSilentResult
|
||||||
|
| TopicProcessorChatReplyResult
|
||||||
|
| TopicProcessorPurchaseResult
|
||||||
|
| TopicProcessorClarificationResult
|
||||||
|
| TopicProcessorPaymentResult
|
||||||
|
| TopicProcessorTopicHelperResult
|
||||||
|
| TopicProcessorDismissWorkflowResult
|
||||||
|
|
||||||
|
export interface TopicProcessorHouseholdMember {
|
||||||
|
memberId: string
|
||||||
|
displayName: string
|
||||||
|
status: 'active' | 'away' | 'left'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
speaker: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorTurn {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicProcessorInput {
|
||||||
|
locale: 'en' | 'ru'
|
||||||
|
topicRole: 'purchase' | 'payments' | 'generic'
|
||||||
|
messageText: string
|
||||||
|
isExplicitMention: boolean
|
||||||
|
isReplyToBot: boolean
|
||||||
|
activeWorkflow: TopicWorkflowState
|
||||||
|
defaultCurrency: 'GEL' | 'USD'
|
||||||
|
householdContext: string | null
|
||||||
|
assistantTone: string | null
|
||||||
|
householdMembers: readonly TopicProcessorHouseholdMember[]
|
||||||
|
senderMemberId: string | null
|
||||||
|
recentThreadMessages: readonly TopicProcessorMessage[]
|
||||||
|
recentChatMessages: readonly TopicProcessorMessage[]
|
||||||
|
recentTurns: readonly TopicProcessorTurn[]
|
||||||
|
engagementAssessment: EngagementAssessment
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopicProcessor = (input: TopicProcessorInput) => Promise<TopicProcessorResult | null>
|
||||||
|
|
||||||
|
export function asOptionalBigInt(value: string | null): bigint | null {
|
||||||
|
if (value === null || !/^[0-9]+$/.test(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = BigInt(value)
|
||||||
|
return parsed > 0n ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null {
|
||||||
|
return value === 'GEL' || value === 'USD' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConfidence(value: number): number {
|
||||||
|
const scaled = value >= 0 && value <= 1 ? value * 100 : value
|
||||||
|
return Math.max(0, Math.min(100, Math.round(scaled)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeParticipantMemberIds(
|
||||||
|
value: readonly string[] | null | undefined,
|
||||||
|
householdMembers: readonly TopicProcessorHouseholdMember[]
|
||||||
|
): readonly string[] | null {
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedMemberIds = new Set(householdMembers.map((member) => member.memberId))
|
||||||
|
const normalized = value
|
||||||
|
.map((memberId) => memberId.trim())
|
||||||
|
.filter((memberId) => memberId.length > 0)
|
||||||
|
.filter((memberId, index, all) => all.indexOf(memberId) === index)
|
||||||
|
.filter((memberId) => allowedMemberIds.has(memberId))
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoute(value: string): TopicProcessorRoute {
|
||||||
|
switch (value) {
|
||||||
|
case 'silent':
|
||||||
|
case 'chat_reply':
|
||||||
|
case 'purchase':
|
||||||
|
case 'purchase_clarification':
|
||||||
|
case 'payment':
|
||||||
|
case 'payment_clarification':
|
||||||
|
case 'topic_helper':
|
||||||
|
case 'dismiss_workflow':
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return 'silent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAiStructuredResult {
|
||||||
|
route: TopicProcessorRoute
|
||||||
|
replyText?: string | null
|
||||||
|
clarificationQuestion?: string | null
|
||||||
|
amountMinor?: string | null
|
||||||
|
currency?: 'GEL' | 'USD' | null
|
||||||
|
itemDescription?: string | null
|
||||||
|
amountSource?: 'explicit' | 'calculated' | null
|
||||||
|
calculationExplanation?: string | null
|
||||||
|
participantMemberIds?: string[] | null
|
||||||
|
kind?: 'rent' | 'utilities' | null
|
||||||
|
confidence?: number
|
||||||
|
reason?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContextSection(input: TopicProcessorInput): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
parts.push(`User locale: ${input.locale}`)
|
||||||
|
parts.push(`Topic role: ${input.topicRole}`)
|
||||||
|
parts.push(`Default currency: ${input.defaultCurrency}`)
|
||||||
|
parts.push(`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`)
|
||||||
|
parts.push(`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`)
|
||||||
|
parts.push(`Active workflow: ${input.activeWorkflow ?? 'none'}`)
|
||||||
|
parts.push(
|
||||||
|
`Engagement: engaged=${input.engagementAssessment.engaged ? 'yes' : 'no'}; reason=${input.engagementAssessment.reason}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (input.householdContext) {
|
||||||
|
parts.push(`Household context: ${input.householdContext}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.householdMembers.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
'Household members:\n' +
|
||||||
|
input.householdMembers
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`- ${m.memberId}: ${m.displayName} (status=${m.status}${m.memberId === input.senderMemberId ? ', sender=yes' : ''})`
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecentMessagesSection(input: TopicProcessorInput): string | null {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (input.recentThreadMessages.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
'Recent messages in this thread:\n' +
|
||||||
|
input.recentThreadMessages
|
||||||
|
.slice(-8)
|
||||||
|
.map((m) => `${m.speaker} (${m.role}): ${m.text}`)
|
||||||
|
.join('\n')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.recentChatMessages.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
'Recent chat messages:\n' +
|
||||||
|
input.recentChatMessages
|
||||||
|
.slice(-6)
|
||||||
|
.map((m) => `${m.speaker} (${m.role}): ${m.text}`)
|
||||||
|
.join('\n')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.recentTurns.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
'Recent conversation with this user:\n' +
|
||||||
|
input.recentTurns
|
||||||
|
.slice(-4)
|
||||||
|
.map((t) => `${t.role}: ${t.text}`)
|
||||||
|
.join('\n')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join('\n\n') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTopicProcessor(
|
||||||
|
apiKey: string | undefined,
|
||||||
|
model: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): TopicProcessor | undefined {
|
||||||
|
if (!apiKey) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (input) => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const timeout = setTimeout(() => abortController.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contextSection = buildContextSection(input)
|
||||||
|
const messagesSection = buildRecentMessagesSection(input)
|
||||||
|
|
||||||
|
const response = await fetch('https://api.openai.com/v1/responses', {
|
||||||
|
method: 'POST',
|
||||||
|
signal: abortController.signal,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${apiKey}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'system',
|
||||||
|
content: `You are the brain of Kojori, a household Telegram bot. You process every message in a topic and decide the right action.
|
||||||
|
|
||||||
|
=== WHEN TO STAY SILENT ===
|
||||||
|
- Default to silent in group topics unless one of the following is true:
|
||||||
|
- The message reports a completed purchase or payment (your primary purpose in these topics)
|
||||||
|
- The user addresses the bot (by @mention, reply to bot, or text reference in ANY language — бот, bot, kojori, кожори, or any recognizable variant)
|
||||||
|
- There is an active clarification/confirmation workflow for this user
|
||||||
|
- The user is clearly engaged with the bot (recent bot interaction, strong context reference)
|
||||||
|
- Regular chat between users (plans, greetings, discussion) → silent
|
||||||
|
|
||||||
|
=== PURCHASE TOPIC (topicRole=purchase) ===
|
||||||
|
Purchase detection is CONTENT-BASED — engagement signals are irrelevant for this decision.
|
||||||
|
If the message reports a completed purchase (past-tense buy verb + realistic item + amount), classify as "purchase" REGARDLESS of mention/engagement.
|
||||||
|
- Completed buy verbs: купил, bought, ordered, picked up, spent, взял, заказал, потратил, сходил взял, etc.
|
||||||
|
- Realistic household items: food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant
|
||||||
|
- Amount under 500 currency units for household purchases
|
||||||
|
- Gifts for household members ARE shared purchases
|
||||||
|
- Plans, wishes, future intent → silent (NOT purchases)
|
||||||
|
- Fantastical items (car, plane, island) or excessive amounts (>500) → chat_reply with playful response
|
||||||
|
|
||||||
|
When classifying as "purchase":
|
||||||
|
- amountMinor in minor currency units (350 GEL → 35000, 3.50 → 350)
|
||||||
|
- Compute totals from quantity × price when needed, set amountSource="calculated"
|
||||||
|
- If user names specific household members as participants, return their memberIds
|
||||||
|
- Use clarification when amount, item, or intent is unclear but purchase seems likely
|
||||||
|
|
||||||
|
=== PAYMENT TOPIC (topicRole=payments) ===
|
||||||
|
If the message reports a completed rent or utility payment (payment verb + rent/utilities + amount), classify as "payment".
|
||||||
|
- Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил
|
||||||
|
- Realistic amount for rent/utilities
|
||||||
|
|
||||||
|
=== CHAT REPLIES ===
|
||||||
|
CRITICAL: chat_reply replyText must NEVER claim a purchase or payment was saved, recorded, confirmed, or logged. The chat_reply route does NOT save anything. Only "purchase" and "payment" routes process real data.
|
||||||
|
|
||||||
|
=== BOT ADDRESSING ===
|
||||||
|
When the user addresses the bot (by any means), you MUST respond — never silent.
|
||||||
|
For bare summons ("бот?", "bot", "@kojori_bot"), use topic_helper to let the assistant greet.
|
||||||
|
For small talk or jokes directed at the bot, use chat_reply with a short playful response.
|
||||||
|
For questions that need household knowledge, use topic_helper.
|
||||||
|
|
||||||
|
=== WORKFLOWS ===
|
||||||
|
If there is an active clarification workflow and the user's message answers it, combine with context.
|
||||||
|
If user dismisses ("не, забей", "cancel"), use dismiss_workflow.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [contextSection, messagesSection, `Latest message:\n${input.messageText}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
name: 'topic_processor_result',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
route: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'silent',
|
||||||
|
'chat_reply',
|
||||||
|
'purchase',
|
||||||
|
'purchase_clarification',
|
||||||
|
'payment',
|
||||||
|
'payment_clarification',
|
||||||
|
'topic_helper',
|
||||||
|
'dismiss_workflow'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
replyText: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
clarificationQuestion: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
amountMinor: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
anyOf: [{ type: 'string', enum: ['GEL', 'USD'] }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
itemDescription: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
amountSource: {
|
||||||
|
anyOf: [{ type: 'string', enum: ['explicit', 'calculated'] }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
calculationExplanation: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
participantMemberIds: {
|
||||||
|
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
anyOf: [{ type: 'string', enum: ['rent', 'utilities'] }, { type: 'null' }]
|
||||||
|
},
|
||||||
|
confidence: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 100
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['route', 'confidence', 'reason']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as Record<string, unknown>
|
||||||
|
const text = extractOpenAiResponseText(payload)
|
||||||
|
const parsed = parseJsonFromResponseText<OpenAiStructuredResult>(text ?? '')
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = normalizeRoute(typeof parsed.route === 'string' ? parsed.route : 'silent')
|
||||||
|
const confidence = normalizeConfidence(
|
||||||
|
typeof parsed.confidence === 'number' ? parsed.confidence : 0
|
||||||
|
)
|
||||||
|
const reason = typeof parsed.reason === 'string' ? parsed.reason : 'unknown'
|
||||||
|
|
||||||
|
switch (route) {
|
||||||
|
case 'silent':
|
||||||
|
return { route, reason }
|
||||||
|
|
||||||
|
case 'chat_reply': {
|
||||||
|
const replyText =
|
||||||
|
typeof parsed.replyText === 'string' && parsed.replyText.trim().length > 0
|
||||||
|
? parsed.replyText.trim()
|
||||||
|
: null
|
||||||
|
if (!replyText) {
|
||||||
|
return { route: 'silent', reason: 'empty_chat_reply' }
|
||||||
|
}
|
||||||
|
return { route, replyText, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'purchase': {
|
||||||
|
const amountMinor = asOptionalBigInt(parsed.amountMinor ?? null)
|
||||||
|
const currency = normalizeCurrency(parsed.currency ?? null)
|
||||||
|
const itemDescription =
|
||||||
|
typeof parsed.itemDescription === 'string' && parsed.itemDescription.trim().length > 0
|
||||||
|
? parsed.itemDescription.trim()
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!amountMinor || !currency || !itemDescription) {
|
||||||
|
return {
|
||||||
|
route: 'purchase_clarification',
|
||||||
|
clarificationQuestion: 'Could you clarify the purchase details?',
|
||||||
|
reason: 'missing_required_fields'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantMemberIds = normalizeParticipantMemberIds(
|
||||||
|
parsed.participantMemberIds,
|
||||||
|
input.householdMembers
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
route,
|
||||||
|
amountMinor: amountMinor.toString(),
|
||||||
|
currency,
|
||||||
|
itemDescription,
|
||||||
|
amountSource: parsed.amountSource === 'calculated' ? 'calculated' : 'explicit',
|
||||||
|
calculationExplanation:
|
||||||
|
typeof parsed.calculationExplanation === 'string' &&
|
||||||
|
parsed.calculationExplanation.trim().length > 0
|
||||||
|
? parsed.calculationExplanation.trim()
|
||||||
|
: null,
|
||||||
|
participantMemberIds: participantMemberIds ? [...participantMemberIds] : null,
|
||||||
|
confidence,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'purchase_clarification':
|
||||||
|
case 'payment_clarification': {
|
||||||
|
const clarificationQuestion =
|
||||||
|
typeof parsed.clarificationQuestion === 'string' &&
|
||||||
|
parsed.clarificationQuestion.trim().length > 0
|
||||||
|
? parsed.clarificationQuestion.trim()
|
||||||
|
: 'Could you clarify?'
|
||||||
|
return { route, clarificationQuestion, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'payment': {
|
||||||
|
const amountMinor = asOptionalBigInt(parsed.amountMinor ?? null)
|
||||||
|
const currency = normalizeCurrency(parsed.currency ?? null)
|
||||||
|
const kind = parsed.kind === 'rent' || parsed.kind === 'utilities' ? parsed.kind : null
|
||||||
|
|
||||||
|
if (!amountMinor || !currency || !kind) {
|
||||||
|
return {
|
||||||
|
route: 'payment_clarification',
|
||||||
|
clarificationQuestion: 'Could you clarify the payment details?',
|
||||||
|
reason: 'missing_required_fields'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
route,
|
||||||
|
kind,
|
||||||
|
amountMinor: amountMinor.toString(),
|
||||||
|
currency,
|
||||||
|
confidence,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'topic_helper':
|
||||||
|
return { route, reason }
|
||||||
|
|
||||||
|
case 'dismiss_workflow': {
|
||||||
|
const replyText =
|
||||||
|
typeof parsed.replyText === 'string' && parsed.replyText.trim().length > 0
|
||||||
|
? parsed.replyText.trim()
|
||||||
|
: null
|
||||||
|
return { route, replyText, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { route: 'silent', reason: 'unknown_route' }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function botSleepsMessage(locale: 'en' | 'ru' | string): string {
|
||||||
|
const enMessages = [
|
||||||
|
'😴 Kojori is taking a quick nap... try again in a moment!',
|
||||||
|
'💤 The bot is recharging its circuits... be right back!',
|
||||||
|
'🌙 Kojori went to grab some digital coffee...',
|
||||||
|
'⚡ Power nap in progress... zzz...'
|
||||||
|
]
|
||||||
|
const ruMessages = [
|
||||||
|
'😴 Кожори немного вздремнул... попробуйте ещё раз через минутку!',
|
||||||
|
'💤 Бот подзаряжает свои схемы... скоро вернётся!',
|
||||||
|
'🌙 Кожори сбегал за цифровым кофе...',
|
||||||
|
'⚡ Идёт подзарядка... zzz...'
|
||||||
|
]
|
||||||
|
|
||||||
|
const messages = locale === 'ru' ? ruMessages : enMessages
|
||||||
|
return messages[Math.floor(Math.random() * messages.length)]!
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
|
|||||||
- `bot_mini_app_allowed_origins`
|
- `bot_mini_app_allowed_origins`
|
||||||
- optional `bot_purchase_parser_model`
|
- optional `bot_purchase_parser_model`
|
||||||
- optional `bot_assistant_model`
|
- optional `bot_assistant_model`
|
||||||
- optional `bot_assistant_router_model`
|
- optional `bot_topic_processor_model`
|
||||||
|
|
||||||
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
|
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
|
||||||
Do not rely on permissive origin reflection in production.
|
Do not rely on permissive origin reflection in production.
|
||||||
|
|||||||
Reference in New Issue
Block a user