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:
2026-03-14 13:33:57 +04:00
parent 9c3bb100e3
commit f38ee499ae
14 changed files with 1554 additions and 854 deletions

View File

@@ -1,174 +1,136 @@
import { describe, expect, test } from 'bun:test'
import { createOpenAiTopicMessageRouter } from './topic-message-router'
import { fallbackTopicMessageRoute } from './topic-message-router'
function successfulResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
'content-type': 'application/json'
}
})
}
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 {
const route = await router!({
locale: 'ru',
topicRole: 'purchase',
messageText: 'Я хочу рыбу. Завтра подумаю, примерно 20 лари.',
isExplicitMention: true,
isReplyToBot: false,
activeWorkflow: null
})
expect(route).toMatchObject({
route: 'purchase_candidate',
helperKind: 'purchase',
shouldStartTyping: true,
shouldClearWorkflow: false,
reason: 'llm_purchase_guess'
})
} finally {
globalThis.fetch = originalFetch
}
describe('fallbackTopicMessageRoute', () => {
test('returns silent for empty messages', () => {
const route = fallbackTopicMessageRoute({
locale: 'en',
topicRole: 'purchase',
messageText: '',
isExplicitMention: false,
isReplyToBot: false,
activeWorkflow: null
})
expect(route.route).toBe('silent')
expect(route.reason).toBe('empty')
})
test('does not override purchase followups for meta references', 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: 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,
isReplyToBot: true,
activeWorkflow: 'purchase_clarification'
})
expect(route).toMatchObject({
route: 'purchase_followup',
helperKind: 'purchase',
shouldStartTyping: false,
shouldClearWorkflow: false,
reason: 'llm_followup_guess'
})
} finally {
globalThis.fetch = originalFetch
}
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('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,
isReplyToBot: true,
activeWorkflow: 'payment_clarification'
})
expect(route).toMatchObject({
route: 'payment_followup',
helperKind: 'payment',
shouldStartTyping: false,
shouldClearWorkflow: false,
reason: 'llm_payment_followup'
})
} finally {
globalThis.fetch = originalFetch
}
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('keeps purchase followups for approximate clarification answers', async () => {
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
expect(router).toBeDefined()
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')
})
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
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')
})
try {
const route = await router!({
locale: 'ru',
topicRole: 'purchase',
messageText: 'примерно 20 лари',
isExplicitMention: false,
isReplyToBot: true,
activeWorkflow: 'purchase_clarification'
})
test('returns topic_helper for weak session', () => {
const route = fallbackTopicMessageRoute({
locale: 'en',
topicRole: 'generic',
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')
})
expect(route).toMatchObject({
route: 'purchase_followup',
helperKind: 'purchase',
shouldStartTyping: true,
shouldClearWorkflow: false,
reason: 'llm_purchase_followup'
})
} finally {
globalThis.fetch = originalFetch
}
test('returns topic_helper for explicit mention', () => {
const route = fallbackTopicMessageRoute({
locale: 'en',
topicRole: 'generic',
messageText: 'some message',
isExplicitMention: true,
isReplyToBot: false,
activeWorkflow: null
})
expect(route.route).toBe('topic_helper')
expect(route.helperKind).toBe('assistant')
})
test('returns topic_helper for reply to bot', () => {
const route = fallbackTopicMessageRoute({
locale: 'en',
topicRole: 'generic',
messageText: 'some message',
isExplicitMention: false,
isReplyToBot: true,
activeWorkflow: null
})
expect(route.route).toBe('topic_helper')
expect(route.helperKind).toBe('assistant')
})
test('returns silent by default', () => {
const route = fallbackTopicMessageRoute({
locale: 'en',
topicRole: 'generic',
messageText: 'some message',
isExplicitMention: false,
isReplyToBot: false,
activeWorkflow: null
})
expect(route.route).toBe('silent')
expect(route.reason).toBe('quiet_default')
})
})