mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 00:24:03 +00:00
feat(bot): add shared topic router
This commit is contained in:
421
apps/bot/src/topic-message-router.ts
Normal file
421
apps/bot/src/topic-message-router.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import type { Context } from 'grammy'
|
||||
|
||||
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
|
||||
|
||||
export type TopicMessageRole = 'generic' | 'purchase' | 'payments' | 'reminders' | 'feedback'
|
||||
export type TopicWorkflowState =
|
||||
| 'purchase_clarification'
|
||||
| 'payment_clarification'
|
||||
| 'payment_confirmation'
|
||||
| null
|
||||
export type TopicMessageRoute =
|
||||
| 'silent'
|
||||
| 'chat_reply'
|
||||
| 'purchase_candidate'
|
||||
| 'purchase_followup'
|
||||
| 'payment_candidate'
|
||||
| 'payment_followup'
|
||||
| 'topic_helper'
|
||||
| 'dismiss_workflow'
|
||||
|
||||
export interface TopicMessageRoutingInput {
|
||||
locale: 'en' | 'ru'
|
||||
topicRole: TopicMessageRole
|
||||
messageText: string
|
||||
isExplicitMention: boolean
|
||||
isReplyToBot: boolean
|
||||
activeWorkflow: TopicWorkflowState
|
||||
assistantContext?: string | null
|
||||
assistantTone?: string | null
|
||||
recentTurns?: readonly {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface TopicMessageRoutingResult {
|
||||
route: TopicMessageRoute
|
||||
replyText: string | null
|
||||
helperKind: 'assistant' | 'purchase' | 'payment' | 'reminder' | null
|
||||
shouldStartTyping: boolean
|
||||
shouldClearWorkflow: boolean
|
||||
confidence: number
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export type TopicMessageRouter = (
|
||||
input: TopicMessageRoutingInput
|
||||
) => Promise<TopicMessageRoutingResult>
|
||||
|
||||
const topicMessageRouteCacheKey = Symbol('topic-message-route-cache')
|
||||
|
||||
type CachedTopicMessageRole = Extract<TopicMessageRole, 'purchase' | 'payments'>
|
||||
|
||||
type TopicMessageRouteCacheEntry = {
|
||||
topicRole: CachedTopicMessageRole
|
||||
route: TopicMessageRoutingResult
|
||||
}
|
||||
|
||||
type ContextWithTopicMessageRouteCache = Context & {
|
||||
[topicMessageRouteCacheKey]?: TopicMessageRouteCacheEntry
|
||||
}
|
||||
|
||||
const BACKOFF_PATTERN =
|
||||
/\b(?:leave me alone|go away|stop|not now|back off|shut up)\b|(?:^|[^\p{L}])(?:отстань|хватит|не сейчас|замолчи|оставь(?:\s+меня)?\s+в\s+покое)(?=$|[^\p{L}])/iu
|
||||
const PLANNING_PATTERN =
|
||||
/\b(?:want to buy|thinking about buying|thinking of buying|going to buy|plan to buy|might buy)\b|(?:^|[^\p{L}])(?:хочу|думаю|планирую|может)\s+(?:купить|взять|заказать)(?=$|[^\p{L}])/iu
|
||||
const LIKELY_PURCHASE_PATTERN =
|
||||
/\b(?:bought|ordered|picked up|spent|paid)\b|(?:^|[^\p{L}])(?:купил(?:а|и)?|взял(?:а|и)?|заказал(?:а|и)?|потратил(?:а|и)?|заплатил(?:а|и)?|сторговался(?:\s+до)?)(?=$|[^\p{L}])/iu
|
||||
const LIKELY_PAYMENT_PATTERN =
|
||||
/\b(?:paid rent|paid utilities|rent paid|utilities paid)\b|(?:^|[^\p{L}])(?:оплатил(?:а|и)?|заплатил(?:а|и)?)(?=$|[^\p{L}])/iu
|
||||
const LETTER_PATTERN = /\p{L}/u
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
function fallbackReply(locale: 'en' | 'ru', kind: 'backoff' | 'watching'): string {
|
||||
if (locale === 'ru') {
|
||||
return kind === 'backoff'
|
||||
? 'Окей, молчу.'
|
||||
: 'Я тут. Если будет реальная покупка или оплата, подключусь.'
|
||||
}
|
||||
|
||||
return kind === 'backoff'
|
||||
? "Okay, I'll back off."
|
||||
: "I'm here. If there's a real purchase or payment, I'll jump in."
|
||||
}
|
||||
|
||||
export function fallbackTopicMessageRoute(
|
||||
input: TopicMessageRoutingInput
|
||||
): TopicMessageRoutingResult {
|
||||
const normalized = input.messageText.trim()
|
||||
const isAddressed = input.isExplicitMention || input.isReplyToBot
|
||||
|
||||
if (normalized.length === 0 || !LETTER_PATTERN.test(normalized)) {
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 100,
|
||||
reason: 'empty'
|
||||
}
|
||||
}
|
||||
|
||||
if (BACKOFF_PATTERN.test(normalized)) {
|
||||
return {
|
||||
route: 'dismiss_workflow',
|
||||
replyText: isAddressed ? fallbackReply(input.locale, 'backoff') : null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: input.activeWorkflow !== null,
|
||||
confidence: 94,
|
||||
reason: 'backoff'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.topicRole === 'purchase') {
|
||||
if (input.activeWorkflow === 'purchase_clarification') {
|
||||
return {
|
||||
route: 'purchase_followup',
|
||||
replyText: null,
|
||||
helperKind: 'purchase',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 72,
|
||||
reason: 'active_purchase_workflow'
|
||||
}
|
||||
}
|
||||
|
||||
if (!PLANNING_PATTERN.test(normalized) && LIKELY_PURCHASE_PATTERN.test(normalized)) {
|
||||
return {
|
||||
route: 'purchase_candidate',
|
||||
replyText: null,
|
||||
helperKind: 'purchase',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 70,
|
||||
reason: 'likely_purchase'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.topicRole === 'payments') {
|
||||
if (
|
||||
input.activeWorkflow === 'payment_clarification' ||
|
||||
input.activeWorkflow === 'payment_confirmation'
|
||||
) {
|
||||
return {
|
||||
route: 'payment_followup',
|
||||
replyText: null,
|
||||
helperKind: 'payment',
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 72,
|
||||
reason: 'active_payment_workflow'
|
||||
}
|
||||
}
|
||||
|
||||
if (!PLANNING_PATTERN.test(normalized) && LIKELY_PAYMENT_PATTERN.test(normalized)) {
|
||||
return {
|
||||
route: 'payment_candidate',
|
||||
replyText: null,
|
||||
helperKind: 'payment',
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 68,
|
||||
reason: 'likely_payment'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAddressed) {
|
||||
return {
|
||||
route: 'topic_helper',
|
||||
replyText: null,
|
||||
helperKind: 'assistant',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 60,
|
||||
reason: 'addressed'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 70,
|
||||
reason: 'quiet_default'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function cacheTopicMessageRoute(
|
||||
ctx: Context,
|
||||
topicRole: CachedTopicMessageRole,
|
||||
route: TopicMessageRoutingResult
|
||||
): void {
|
||||
;(ctx as ContextWithTopicMessageRouteCache)[topicMessageRouteCacheKey] = {
|
||||
topicRole,
|
||||
route
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedTopicMessageRoute(
|
||||
ctx: Context,
|
||||
topicRole: CachedTopicMessageRole
|
||||
): TopicMessageRoutingResult | null {
|
||||
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.',
|
||||
'Prefer silence over speaking.',
|
||||
'Do not start purchase or payment workflows for planning, hypotheticals, negotiations, tests, or obvious jokes.',
|
||||
'Treat “stop”, “leave me alone”, “just thinking”, “not a purchase”, and similar messages as backoff or dismissal signals.',
|
||||
'When the user directly addresses the bot with small talk, joking, or testing, prefer chat_reply with one short sentence.',
|
||||
'Use topic_helper only when the message is a real question or request that likely needs household knowledge or a topic-specific helper.',
|
||||
'Use purchase_candidate only for a clear completed shared purchase.',
|
||||
'Use purchase_followup only when there is active purchase clarification and the latest message looks like a real answer to it.',
|
||||
'Use payment_candidate only for a clear payment confirmation.',
|
||||
'Use payment_followup only when there is active payment clarification/confirmation and the latest message looks like a real answer to it.',
|
||||
'For absurd or playful messages, be light and short. Never loop or interrogate.',
|
||||
'Set shouldStartTyping to true only if the chosen route will likely trigger a slower helper or assistant call.',
|
||||
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'}`,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user