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,7 +1,5 @@
import type { Context } from 'grammy'
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
export type TopicMessageRole = 'generic' | 'purchase' | 'payments' | 'reminders' | 'feedback'
export type TopicWorkflowState =
| 'purchase_clarification'
@@ -79,35 +77,6 @@ type ContextWithTopicMessageRouteCache = Context & {
[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(
input: TopicMessageRoutingInput
): 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(
ctx: Context,
topicRole: CachedTopicMessageRole,
@@ -249,201 +181,3 @@ export function getCachedTopicMessageRoute(
const cached = (ctx as ContextWithTopicMessageRouteCache)[topicMessageRouteCacheKey]
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)
}
}
}