mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 05:44:04 +00:00
622 lines
21 KiB
TypeScript
622 lines
21 KiB
TypeScript
import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses'
|
||
import type { TopicWorkflowState } from './topic-message-router'
|
||
import type { EngagementAssessment } from './conversation-orchestrator'
|
||
import { getBotTranslations } from './i18n'
|
||
|
||
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 | null
|
||
currency: 'GEL' | 'USD' | null
|
||
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,
|
||
logger?: {
|
||
error: (obj: unknown, msg?: string) => void
|
||
info: (obj: unknown, msg?: string) => void
|
||
warn: (obj: unknown, msg?: string) => void
|
||
}
|
||
): TopicProcessor | undefined {
|
||
if (!apiKey) {
|
||
return undefined
|
||
}
|
||
|
||
return async (input) => {
|
||
logger?.info(
|
||
{
|
||
event: 'topic_processor.start',
|
||
topicRole: input.topicRole,
|
||
messageText: input.messageText,
|
||
explicitMention: input.isExplicitMention
|
||
},
|
||
'Topic processor starting'
|
||
)
|
||
|
||
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
|
||
- Regular chat between users (plans, greetings, discussion) → silent
|
||
|
||
=== PURCHASE TOPIC (topicRole=purchase) ===
|
||
Purchase detection is CONTENT-BASED. This topic is a workflow topic, not a casual assistant thread.
|
||
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
|
||
- If the user explicitly addresses the bot with non-purchase banter, use chat_reply with one short sentence.
|
||
- Do not use topic_helper for casual banter in the purchase topic.
|
||
|
||
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) ===
|
||
This topic is also a workflow topic, not a casual assistant thread.
|
||
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
|
||
- If the message is a payment-related balance/status question, use topic_helper.
|
||
- If the user explicitly addresses the bot with non-payment banter, use chat_reply with one short sentence.
|
||
- Otherwise ordinary discussion in this topic stays silent.
|
||
|
||
=== 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 should respond briefly, but finance topics still stay workflow-focused.
|
||
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.
|
||
|
||
=== LANGUAGE ===
|
||
- Always use the user's locale (locale=${input.locale}) for clarificationQuestion and replyText.
|
||
- If locale=ru, respond in Russian. If locale=en, respond in English.
|
||
|
||
=== 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',
|
||
'replyText',
|
||
'clarificationQuestion',
|
||
'amountMinor',
|
||
'currency',
|
||
'itemDescription',
|
||
'amountSource',
|
||
'calculationExplanation',
|
||
'participantMemberIds',
|
||
'kind',
|
||
'confidence',
|
||
'reason'
|
||
]
|
||
}
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
if (!response.ok) {
|
||
logger?.error(
|
||
{
|
||
event: 'topic_processor.api_error',
|
||
status: response.status,
|
||
text: await response.text()
|
||
},
|
||
'Topic processor API error'
|
||
)
|
||
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)) {
|
||
logger?.error(
|
||
{ event: 'topic_processor.parse_error', text },
|
||
'Topic processor failed to parse response'
|
||
)
|
||
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':
|
||
logger?.info(
|
||
{ event: 'topic_processor.silent', reason },
|
||
'Topic processor decided silent'
|
||
)
|
||
return { route, reason }
|
||
|
||
case 'chat_reply': {
|
||
const replyText =
|
||
typeof parsed.replyText === 'string' && parsed.replyText.trim().length > 0
|
||
? parsed.replyText.trim()
|
||
: null
|
||
if (!replyText) {
|
||
logger?.info(
|
||
{ event: 'topic_processor.empty_chat_reply', reason },
|
||
'Topic processor returned empty chat reply'
|
||
)
|
||
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) {
|
||
logger?.warn(
|
||
{
|
||
event: 'topic_processor.missing_purchase_fields',
|
||
amountMinor: parsed.amountMinor,
|
||
currency: parsed.currency,
|
||
itemDescription: parsed.itemDescription
|
||
},
|
||
'Topic processor missing purchase fields'
|
||
)
|
||
const t = getBotTranslations(input.locale).purchase
|
||
return {
|
||
route: 'purchase_clarification',
|
||
clarificationQuestion: t.clarificationLowConfidence,
|
||
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 t = getBotTranslations(input.locale)
|
||
const defaultQuestion =
|
||
route === 'purchase_clarification'
|
||
? t.purchase.clarificationLowConfidence
|
||
: t.assistant.paymentClarification
|
||
const clarificationQuestion =
|
||
typeof parsed.clarificationQuestion === 'string' &&
|
||
parsed.clarificationQuestion.trim().length > 0
|
||
? parsed.clarificationQuestion.trim()
|
||
: defaultQuestion
|
||
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 (!kind) {
|
||
logger?.warn(
|
||
{
|
||
event: 'topic_processor.missing_payment_fields',
|
||
amountMinor: parsed.amountMinor,
|
||
currency: parsed.currency,
|
||
kind: parsed.kind
|
||
},
|
||
'Topic processor missing payment fields'
|
||
)
|
||
const t = getBotTranslations(input.locale).assistant
|
||
return {
|
||
route: 'payment_clarification',
|
||
clarificationQuestion: t.paymentClarification,
|
||
reason: 'missing_required_fields'
|
||
}
|
||
}
|
||
|
||
return {
|
||
route,
|
||
kind,
|
||
amountMinor: amountMinor?.toString() ?? null,
|
||
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:
|
||
logger?.warn(
|
||
{ event: 'topic_processor.unknown_route', route: parsed.route },
|
||
'Topic processor returned unknown route'
|
||
)
|
||
return { route: 'silent', reason: 'unknown_route' }
|
||
}
|
||
} catch (error) {
|
||
logger?.error({ event: 'topic_processor.failed', error }, 'Topic processor failed')
|
||
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)]!
|
||
}
|