mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:24: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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user