Refine topic workflow followups

This commit is contained in:
2026-03-13 02:21:08 +04:00
parent 88b50d2cb7
commit f1670c521f
11 changed files with 569 additions and 291 deletions

View File

@@ -1807,8 +1807,12 @@ Confirm or cancel below.`,
})
await bot.handleUpdate(topicMessageUpdate('I think we need a TV in the house') as never)
await bot.handleUpdate(topicMessageUpdate('Bot, do you remember what we said today?') as never)
await bot.handleUpdate(topicMessageUpdate('Bot, do you remember what you answered?') as never)
await bot.handleUpdate(
topicMentionUpdate('@household_test_bot do you remember what we said today?') as never
)
await bot.handleUpdate(
topicMentionUpdate('@household_test_bot do you remember what you answered?') as never
)
expect(recentThreadTexts).toContain('I think we need a TV in the house')
expect(recentThreadTexts).toContain('Yes. You were discussing a TV for the house.')
@@ -1886,7 +1890,7 @@ Confirm or cancel below.`,
await bot.handleUpdate(topicMessageUpdate('Я думаю о семечках') as never)
await bot.handleUpdate(
topicMessageUpdate('Бот, можешь дать сводку, что происходило в чате?') as never
topicMentionUpdate('@household_test_bot можешь дать сводку, что происходило в чате?') as never
)
expect(assistantCalls).toBe(1)

View File

@@ -38,11 +38,7 @@ import {
type ConversationHistoryMessage
} from './conversation-orchestrator'
import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router'
import {
fallbackTopicMessageRoute,
getCachedTopicMessageRoute,
looksLikeDirectBotAddress
} from './topic-message-router'
import { fallbackTopicMessageRoute, getCachedTopicMessageRoute } from './topic-message-router'
import {
persistTopicHistoryMessage,
telegramMessageIdFromMessage,
@@ -1311,10 +1307,7 @@ export function registerDmAssistant(options: {
}
const mention = stripExplicitBotMention(ctx)
const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text)
const isExplicitMention = Boolean(
(mention && mention.strippedText.length > 0) || directAddressByText
)
const isExplicitMention = Boolean(mention && mention.strippedText.length > 0)
const isReplyToBot = isReplyToBotMessage(ctx)
const telegramUserId = ctx.from?.id?.toString()
@@ -1417,7 +1410,7 @@ export function registerDmAssistant(options: {
messageText,
explicitMention: isExplicitMention,
replyToBot: isReplyToBot,
directBotAddress: directAddressByText,
directBotAddress: false,
memoryStore: options.memoryStore
})
const route =

View File

@@ -94,8 +94,13 @@ const ASSISTANT_SYSTEM_PROMPT = [
'For unsupported writes, explain the limitation briefly and suggest the explicit command or confirmation flow.',
'Prefer concise, practical answers.',
'Default to one to three short sentences.',
'For a bare summon such as “bot?”, “pss bot”, or “ты тут?”, acknowledge briefly instead of acting confused.',
'Do not assume the user is addressing you just because they mention "bot" or use an attention-grabbing word; they may be talking about the bot or to someone else.',
'For simple greetings or small talk, reply in a single short sentence unless the user asks for more.',
'If the user is joking or testing you, you may answer playfully in one short sentence.',
'Do not tack on “how can I help” style follow-up questions after every casual or successful turn.',
'If the exchange is already playful, keep that tone for the next turn instead of snapping back to generic assistant phrasing.',
'Treat obviously impossible or fantastical purchases, payments, and travel plans as jokes or hypotheticals unless the user clearly turns them into a real household action.',
'When the user refers to something said above, earlier, already mentioned, or in the dialog, answer from the provided conversation history if the answer is there.',
'For dialogue-memory questions, prioritize recent topic thread messages first, then same-day chat history, then per-user memory summary.',
'Do not ask the user to repeat information that is already present in the provided conversation history.',

View File

@@ -32,7 +32,7 @@ describe('createOpenAiPurchaseInterpreter', () => {
)
})
test('returns not_purchase for planning chatter without calling the llm', async () => {
test('delegates planning chatter to the llm', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
expect(interpreter).toBeDefined()
@@ -40,7 +40,27 @@ describe('createOpenAiPurchaseInterpreter', () => {
let fetchCalls = 0
globalThis.fetch = (async () => {
fetchCalls += 1
return successfulResponse({})
return successfulResponse({
output: [
{
content: [
{
text: JSON.stringify({
decision: 'not_purchase',
amountMinor: null,
currency: null,
itemDescription: null,
amountSource: null,
calculationExplanation: null,
participantMemberIds: null,
confidence: 94,
clarificationQuestion: null
})
}
]
}
]
})
}) as unknown as typeof fetch
try {
@@ -59,13 +79,13 @@ describe('createOpenAiPurchaseInterpreter', () => {
parserMode: 'llm',
clarificationQuestion: null
})
expect(fetchCalls).toBe(0)
expect(fetchCalls).toBe(1)
} finally {
globalThis.fetch = originalFetch
}
})
test('returns not_purchase for meta references without calling the llm', async () => {
test('delegates bare meta references to the llm', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
expect(interpreter).toBeDefined()
@@ -73,7 +93,27 @@ describe('createOpenAiPurchaseInterpreter', () => {
let fetchCalls = 0
globalThis.fetch = (async () => {
fetchCalls += 1
return successfulResponse({})
return successfulResponse({
output: [
{
content: [
{
text: JSON.stringify({
decision: 'not_purchase',
amountMinor: null,
currency: null,
itemDescription: null,
amountSource: null,
calculationExplanation: null,
participantMemberIds: null,
confidence: 94,
clarificationQuestion: null
})
}
]
}
]
})
}) as unknown as typeof fetch
try {
@@ -92,7 +132,7 @@ describe('createOpenAiPurchaseInterpreter', () => {
parserMode: 'llm',
clarificationQuestion: null
})
expect(fetchCalls).toBe(0)
expect(fetchCalls).toBe(1)
} finally {
globalThis.fetch = originalFetch
}
@@ -201,6 +241,74 @@ describe('createOpenAiPurchaseInterpreter', () => {
}
})
test('parses explicit participant member ids from the household roster', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
expect(interpreter).toBeDefined()
const originalFetch = globalThis.fetch
globalThis.fetch = (async () =>
successfulResponse({
output: [
{
content: [
{
text: JSON.stringify({
decision: 'purchase',
amountMinor: '2000',
currency: 'GEL',
itemDescription: 'мороженое',
amountSource: 'explicit',
calculationExplanation: null,
participantMemberIds: ['member-stas', 'member-alice', 'unknown-member'],
confidence: 88,
clarificationQuestion: null
})
}
]
}
]
})) as unknown as typeof fetch
try {
const result = await interpreter!('Да, еще купил мороженного себе и Алисе на 20 лари', {
defaultCurrency: 'GEL',
senderMemberId: 'member-stas',
householdMembers: [
{
memberId: 'member-stas',
displayName: 'Stas',
status: 'active'
},
{
memberId: 'member-alice',
displayName: 'Alice',
status: 'away'
},
{
memberId: 'member-dima',
displayName: 'Dima',
status: 'active'
}
]
})
expect(result).toEqual<PurchaseInterpretation>({
decision: 'purchase',
amountMinor: 2000n,
currency: 'GEL',
itemDescription: 'мороженое',
amountSource: 'explicit',
calculationExplanation: null,
participantMemberIds: ['member-stas', 'member-alice'],
confidence: 88,
parserMode: 'llm',
clarificationQuestion: null
})
} finally {
globalThis.fetch = originalFetch
}
})
test('parses nested responses api content output', async () => {
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
expect(interpreter).toBeDefined()

View File

@@ -3,6 +3,12 @@ import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-r
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
export type PurchaseInterpretationAmountSource = 'explicit' | 'calculated'
export interface PurchaseInterpreterHouseholdMember {
memberId: string
displayName: string
status: 'active' | 'away' | 'left'
}
export interface PurchaseInterpretation {
decision: PurchaseInterpretationDecision
amountMinor: bigint | null
@@ -10,6 +16,7 @@ export interface PurchaseInterpretation {
itemDescription: string | null
amountSource?: PurchaseInterpretationAmountSource | null
calculationExplanation?: string | null
participantMemberIds?: readonly string[] | null
confidence: number
parserMode: 'llm'
clarificationQuestion: string | null
@@ -26,6 +33,8 @@ export type PurchaseMessageInterpreter = (
clarificationContext?: PurchaseClarificationContext
householdContext?: string | null
assistantTone?: string | null
householdMembers?: readonly PurchaseInterpreterHouseholdMember[]
senderMemberId?: string | null
}
) => Promise<PurchaseInterpretation | null>
@@ -36,18 +45,11 @@ interface OpenAiStructuredResult {
itemDescription: string | null
amountSource: PurchaseInterpretationAmountSource | null
calculationExplanation: string | null
participantMemberIds: string[] | null
confidence: number
clarificationQuestion: string | null
}
const PLANNING_ONLY_PATTERN =
/\b(?:want to buy|thinking about|thinking of|plan to buy|planning to buy|going to buy|might buy|tomorrow|later)\b|(?:^|[^\p{L}])(?:(?:хочу|хотим|думаю|планирую|планируем|может)\s+(?:купить|взять|заказать)|(?:подумаю|завтра|потом))(?=$|[^\p{L}])/iu
const COMPLETED_PURCHASE_PATTERN =
/\b(?:bought|purchased|ordered|picked up|grabbed|got|spent|paid)\b|(?:^|[^\p{L}])(?:купил(?:а|и)?|взял(?:а|и)?|заказал(?:а|и)?|потратил(?:а|и)?|заплатил(?:а|и)?|сторговался(?:\s+до)?)(?=$|[^\p{L}])/iu
const META_REFERENCE_PATTERN =
/\b(?:already said(?: above)?|said above|question above|have context|from the dialog(?:ue)?|based on the dialog(?:ue)?)\b|(?:^|[^\p{L}])(?:я\s+уже\s+сказал(?:\s+выше)?|уже\s+сказал(?:\s+выше)?|вопрос\s+выше|это\s+вопрос|контекст(?:\s+диалога)?|основываясь\s+на\s+диалоге)(?=$|[^\p{L}])/iu
const META_REFERENCE_STRIP_PATTERN = new RegExp(META_REFERENCE_PATTERN.source, 'giu')
function asOptionalBigInt(value: string | null): bigint | null {
if (value === null || !/^[0-9]+$/.test(value)) {
return null
@@ -82,6 +84,26 @@ function normalizeConfidence(value: number): number {
return Math.max(0, Math.min(100, Math.round(scaled)))
}
function normalizeParticipantMemberIds(
value: readonly string[] | null | undefined,
householdMembers: readonly PurchaseInterpreterHouseholdMember[] | undefined
): readonly string[] | null {
if (!value || value.length === 0) {
return null
}
const allowedMemberIds = householdMembers
? new Set(householdMembers.map((member) => member.memberId))
: null
const normalized = value
.map((memberId) => memberId.trim())
.filter((memberId) => memberId.length > 0)
.filter((memberId, index, all) => all.indexOf(memberId) === index)
.filter((memberId) => (allowedMemberIds ? allowedMemberIds.has(memberId) : true))
return normalized.length > 0 ? normalized : null
}
function resolveMissingCurrency(input: {
decision: PurchaseInterpretationDecision
amountMinor: bigint | null
@@ -125,33 +147,6 @@ export function buildPurchaseInterpretationInput(
].join('\n')
}
function isBareMetaReference(rawText: string): boolean {
const normalized = rawText.trim()
if (!META_REFERENCE_PATTERN.test(normalized)) {
return false
}
const stripped = normalized
.replace(META_REFERENCE_STRIP_PATTERN, ' ')
.replace(/[\s,.:;!?()[\]{}"'`-]+/gu, ' ')
.trim()
return stripped.length === 0
}
function shouldReturnNotPurchase(rawText: string): boolean {
const normalized = rawText.trim()
if (normalized.length === 0) {
return true
}
if (isBareMetaReference(normalized)) {
return true
}
return PLANNING_ONLY_PATTERN.test(normalized) && !COMPLETED_PURCHASE_PATTERN.test(normalized)
}
export function createOpenAiPurchaseInterpreter(
apiKey: string | undefined,
model: string
@@ -161,7 +156,7 @@ export function createOpenAiPurchaseInterpreter(
}
return async (rawText, options) => {
if (shouldReturnNotPurchase(rawText)) {
if (rawText.trim().length === 0) {
return {
decision: 'not_purchase',
amountMinor: null,
@@ -195,12 +190,17 @@ export function createOpenAiPurchaseInterpreter(
'Set amountSource to "explicit" when the user directly states the total amount, or "calculated" when you compute it from quantity x price or similar arithmetic.',
'When amountSource is "calculated", also return a short calculationExplanation in the user message language, such as "5 × 6 lari = 30 lari".',
'Ignore item quantities like rolls, kilograms, or layers unless they are clearly the money amount.',
'Infer intent from the message together with any provided context instead of relying on isolated keywords.',
'Treat colloquial completed-buy phrasing like "взял", "сходил и взял", or "сторговался до X" as a completed purchase when the message reports a real buy fact.',
'Plans, wishes, future intent, tomorrow-talk, and approximate future prices are not purchases. Return not_purchase for those.',
'Meta replies like "I already said above", "the question is above", or "do you have context" are not purchase details. Return not_purchase unless the latest message clearly supplies the missing purchase fact.',
'If recent messages from the same sender are provided, treat them as clarification context for the latest message.',
'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.',
'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.',
'If a household member roster is provided and the user explicitly says who shares the purchase, return participantMemberIds as the included member IDs.',
'For phrases like "split with Dima", "for me and Alice", or similar, include the sender and the explicitly mentioned household members in participantMemberIds.',
'If the message does not clearly specify a participant subset, return participantMemberIds as null.',
'Away members may still be included when the user explicitly names them.',
'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.',
'Return a short, natural clarification question in the same language as the user message when clarification is needed.',
'The clarification should sound like a conversational household bot, not a form validator.',
@@ -217,7 +217,20 @@ export function createOpenAiPurchaseInterpreter(
},
{
role: 'user',
content: buildPurchaseInterpretationInput(rawText, options.clarificationContext)
content: [
options.householdMembers && options.householdMembers.length > 0
? [
'Household members:',
...options.householdMembers.map(
(member) =>
`- ${member.memberId}: ${member.displayName} (status=${member.status}${member.memberId === options.senderMemberId ? ', sender=yes' : ''})`
)
].join('\n')
: null,
buildPurchaseInterpretationInput(rawText, options.clarificationContext)
]
.filter(Boolean)
.join('\n\n')
}
],
text: {
@@ -259,6 +272,15 @@ export function createOpenAiPurchaseInterpreter(
calculationExplanation: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
participantMemberIds: {
anyOf: [
{
type: 'array',
items: { type: 'string' }
},
{ type: 'null' }
]
},
confidence: {
type: 'number',
minimum: 0,
@@ -275,6 +297,7 @@ export function createOpenAiPurchaseInterpreter(
'itemDescription',
'amountSource',
'calculationExplanation',
'participantMemberIds',
'confidence',
'clarificationQuestion'
]
@@ -318,6 +341,10 @@ export function createOpenAiPurchaseInterpreter(
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
const participantMemberIds = normalizeParticipantMemberIds(
parsedJson.participantMemberIds,
options.householdMembers
)
const currency = resolveMissingCurrency({
decision: parsedJson.decision,
amountMinor,
@@ -337,7 +364,7 @@ export function createOpenAiPurchaseInterpreter(
return null
}
return {
const result: PurchaseInterpretation = {
decision,
amountMinor,
currency,
@@ -348,5 +375,11 @@ export function createOpenAiPurchaseInterpreter(
parserMode: 'llm',
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
}
if (participantMemberIds) {
result.participantMemberIds = participantMemberIds
}
return result
}
}

View File

@@ -415,6 +415,82 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
})
})
test('clears a pending payment confirmation when a followup has no payment intent', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
const promptRepository = createPromptRepository()
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
await promptRepository.upsertPendingAction({
telegramUserId: '10002',
telegramChatId: '-10012345',
action: 'payment_topic_confirmation',
payload: {
proposalId: 'proposal-1',
householdId: 'household-1',
memberId: 'member-1',
kind: 'rent',
amountMinor: '47250',
currency: 'GEL',
rawText: 'За жилье отправил',
senderTelegramUserId: '10002',
telegramChatId: '-10012345',
telegramMessageId: '55',
telegramThreadId: '888',
telegramUpdateId: '1001',
attachmentCount: 0,
messageSentAt: null
},
expiresAt: null
})
registerConfiguredPaymentTopicIngestion(
bot,
createHouseholdRepository() as never,
promptRepository,
() => createFinanceService(),
() => createPaymentConfirmationService(),
{
router: async () => ({
route: 'payment_followup',
replyText: null,
helperKind: 'payment',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'llm_followup_guess'
})
}
)
await bot.handleUpdate(paymentUpdate('Я уже сказал выше') as never)
expect(calls).toHaveLength(0)
expect(await promptRepository.getPendingAction('-10012345', '10002')).toBeNull()
})
test('confirms a pending payment proposal from a topic callback', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -24,7 +24,6 @@ import {
import {
cacheTopicMessageRoute,
getCachedTopicMessageRoute,
looksLikeDirectBotAddress,
type TopicMessageRouter
} from './topic-message-router'
import {
@@ -285,9 +284,9 @@ async function routePaymentTopicMessage(input: {
topicRole: input.topicRole,
activeWorkflow: input.activeWorkflow,
messageText: input.record.rawText,
explicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText),
explicitMention: input.isExplicitMention,
replyToBot: input.isReplyToBot,
directBotAddress: looksLikeDirectBotAddress(input.record.rawText),
directBotAddress: false,
memoryStore: input.memoryStore ?? {
get() {
return { summary: null, turns: [] }
@@ -302,7 +301,7 @@ async function routePaymentTopicMessage(input: {
locale: input.locale,
topicRole: input.topicRole,
messageText: input.record.rawText,
isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress,
isExplicitMention: conversationContext.explicitMention,
isReplyToBot: conversationContext.replyToBot,
activeWorkflow: input.activeWorkflow,
engagementAssessment: conversationContext.engagement,
@@ -742,6 +741,9 @@ export function registerConfiguredPaymentTopicIngestion(
})
if (proposal.status === 'no_intent') {
if (route.route === 'payment_followup') {
await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId)
}
await next()
return
}

View File

@@ -13,6 +13,7 @@ import {
extractPurchaseTopicCandidate,
registerConfiguredPurchaseTopicIngestion,
registerPurchaseTopicIngestion,
resolveProposalParticipantSelection,
resolveConfiguredPurchaseTopicRecord,
type PurchaseMessageIngestionRepository,
type PurchaseTopicCandidate
@@ -382,6 +383,94 @@ Confirm or cancel below.`)
})
})
describe('resolveProposalParticipantSelection', () => {
test('prefers explicit llm-selected participants over away-status defaults', () => {
const participants = resolveProposalParticipantSelection({
members: [
{
memberId: 'member-stas',
telegramUserId: '10002',
lifecycleStatus: 'active'
},
{
memberId: 'member-dima',
telegramUserId: '10003',
lifecycleStatus: 'active'
},
{
memberId: 'member-alice',
telegramUserId: '10004',
lifecycleStatus: 'away'
}
],
policyByMemberId: new Map([
[
'member-alice',
{
effectiveFromPeriod: '2026-03',
policy: 'away_rent_only'
}
]
]),
senderTelegramUserId: '10002',
senderMemberId: 'member-stas',
explicitParticipantMemberIds: ['member-stas', 'member-alice']
})
expect(participants).toEqual([
{
memberId: 'member-stas',
included: true
},
{
memberId: 'member-dima',
included: false
},
{
memberId: 'member-alice',
included: true
}
])
})
test('falls back to the sender when explicit members are no longer eligible', () => {
const participants = resolveProposalParticipantSelection({
members: [
{
memberId: 'member-stas',
telegramUserId: '10002',
lifecycleStatus: 'active'
},
{
memberId: 'member-dima',
telegramUserId: '10003',
lifecycleStatus: 'active'
},
{
memberId: 'member-alice',
telegramUserId: '10004',
lifecycleStatus: 'left'
}
],
policyByMemberId: new Map(),
senderTelegramUserId: '10002',
senderMemberId: 'member-stas',
explicitParticipantMemberIds: ['member-alice']
})
expect(participants).toEqual([
{
memberId: 'member-stas',
included: true
},
{
memberId: 'member-dima',
included: false
}
])
})
})
describe('registerPurchaseTopicIngestion', () => {
test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
const bot = createTestBot()
@@ -1400,6 +1489,58 @@ Confirm or cancel below.`
})
})
test('clears active purchase clarification when a followup is ignored as not_purchase', async () => {
const bot = createTestBot()
let clearCalls = 0
bot.api.config.use(async () => {
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return true
},
async clearClarificationContext() {
clearCalls += 1
},
async save() {
return {
status: 'ignored_not_purchase',
purchaseMessageId: 'purchase-1'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
router: async () => ({
route: 'purchase_followup',
replyText: null,
helperKind: 'purchase',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 91,
reason: 'llm_followup_guess'
})
})
await bot.handleUpdate(purchaseUpdate('Я уже сказал выше') as never)
expect(clearCalls).toBe(1)
})
test('continues purchase handling for replies to bot messages without a fresh mention', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
@@ -1765,7 +1906,7 @@ Confirm or cancel below.`,
await bot.handleUpdate(
purchaseUpdate('В общем, думаю купить 5 килограмм картошки за 20 лари') as never
)
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?') as never)
await bot.handleUpdate(purchaseUpdate('@household_test_bot что думаешь?') as never)
expect(sawDirectAddress).toBe(true)
expect(recentTurnTexts).toContain('В общем, думаю купить 5 килограмм картошки за 20 лари')

View File

@@ -26,7 +26,6 @@ import type {
import {
cacheTopicMessageRoute,
getCachedTopicMessageRoute,
looksLikeDirectBotAddress,
type TopicMessageRouter,
type TopicMessageRoutingResult
} from './topic-message-router'
@@ -245,6 +244,7 @@ interface PurchasePersistenceDecision {
parsedItemDescription: string | null
amountSource: PurchaseInterpretationAmountSource | null
calculationExplanation: string | null
participantMemberIds: readonly string[] | null
parserConfidence: number | null
parserMode: 'llm' | null
clarificationQuestion: string | null
@@ -306,6 +306,7 @@ function normalizeInterpretation(
parsedItemDescription: null,
amountSource: null,
calculationExplanation: null,
participantMemberIds: null,
parserConfidence: null,
parserMode: null,
clarificationQuestion: null,
@@ -322,6 +323,7 @@ function normalizeInterpretation(
parsedItemDescription: interpretation.itemDescription,
amountSource: interpretation.amountSource ?? null,
calculationExplanation: interpretation.calculationExplanation ?? null,
participantMemberIds: interpretation.participantMemberIds ?? null,
parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode,
clarificationQuestion: null,
@@ -347,6 +349,7 @@ function normalizeInterpretation(
parsedItemDescription: interpretation.itemDescription,
amountSource: interpretation.amountSource ?? null,
calculationExplanation: interpretation.calculationExplanation ?? null,
participantMemberIds: interpretation.participantMemberIds ?? null,
parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode,
clarificationQuestion: interpretation.clarificationQuestion,
@@ -362,6 +365,7 @@ function normalizeInterpretation(
parsedItemDescription: interpretation.itemDescription,
amountSource: interpretation.amountSource ?? null,
calculationExplanation: interpretation.calculationExplanation ?? null,
participantMemberIds: interpretation.participantMemberIds ?? null,
parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode,
clarificationQuestion: null,
@@ -378,6 +382,86 @@ function participantIncludedAsInt(value: boolean): number {
return value ? 1 : 0
}
function normalizeLifecycleStatus(value: string): 'active' | 'away' | 'left' {
return value === 'away' || value === 'left' ? value : 'active'
}
export function resolveProposalParticipantSelection(input: {
members: readonly {
memberId: string
telegramUserId: string | null
lifecycleStatus: 'active' | 'away' | 'left'
}[]
policyByMemberId: ReadonlyMap<
string,
{
effectiveFromPeriod: string
policy: string
}
>
senderTelegramUserId: string
senderMemberId: string | null
explicitParticipantMemberIds: readonly string[] | null
}): readonly { memberId: string; included: boolean }[] {
const eligibleMembers = input.members.filter((member) => member.lifecycleStatus !== 'left')
if (input.explicitParticipantMemberIds && input.explicitParticipantMemberIds.length > 0) {
const explicitMemberIds = new Set(input.explicitParticipantMemberIds)
const explicitParticipants = eligibleMembers.map((member) => ({
memberId: member.memberId,
included: explicitMemberIds.has(member.memberId)
}))
if (explicitParticipants.some((participant) => participant.included)) {
return explicitParticipants
}
const fallbackParticipant =
eligibleMembers.find((member) => member.memberId === input.senderMemberId) ??
eligibleMembers.find((member) => member.telegramUserId === input.senderTelegramUserId) ??
eligibleMembers[0]
return explicitParticipants.map(({ memberId }) => ({
memberId,
included: memberId === fallbackParticipant?.memberId
}))
}
const participants = eligibleMembers.map((member) => {
const policy = input.policyByMemberId.get(member.memberId)?.policy ?? 'resident'
const included =
member.lifecycleStatus === 'away'
? policy === 'resident'
: member.lifecycleStatus === 'active'
return {
memberId: member.memberId,
telegramUserId: member.telegramUserId,
included
}
})
if (participants.length === 0) {
return []
}
if (participants.some((participant) => participant.included)) {
return participants.map(({ memberId, included }) => ({
memberId,
included
}))
}
const fallbackParticipant =
participants.find((participant) => participant.memberId === input.senderMemberId) ??
participants.find((participant) => participant.telegramUserId === input.senderTelegramUserId) ??
participants[0]
return participants.map(({ memberId }) => ({
memberId,
included: memberId === fallbackParticipant?.memberId
}))
}
function toStoredPurchaseRow(row: {
id: string
householdId: string
@@ -779,6 +863,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
senderTelegramUserId: string
senderMemberId: string | null
messageSentAt: Instant
explicitParticipantMemberIds: readonly string[] | null
}): Promise<readonly { memberId: string; included: boolean }[]> {
const [members, settingsRows, policyRows] = await Promise.all([
db
@@ -830,44 +915,17 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
}
}
const participants = members
.filter((member) => member.lifecycleStatus !== 'left')
.map((member) => {
const policy = policyByMemberId.get(member.id)?.policy ?? 'resident'
const included =
member.lifecycleStatus === 'away'
? policy === 'resident'
: member.lifecycleStatus === 'active'
return {
return resolveProposalParticipantSelection({
members: members.map((member) => ({
memberId: member.id,
telegramUserId: member.telegramUserId,
included
}
lifecycleStatus: normalizeLifecycleStatus(member.lifecycleStatus)
})),
policyByMemberId,
senderTelegramUserId: input.senderTelegramUserId,
senderMemberId: input.senderMemberId,
explicitParticipantMemberIds: input.explicitParticipantMemberIds
})
if (participants.length === 0) {
return []
}
if (participants.some((participant) => participant.included)) {
return participants.map(({ memberId, included }) => ({
memberId,
included
}))
}
const fallbackParticipant =
participants.find((participant) => participant.memberId === input.senderMemberId) ??
participants.find(
(participant) => participant.telegramUserId === input.senderTelegramUserId
) ??
participants[0]
return participants.map(({ memberId }) => ({
memberId,
included: memberId === fallbackParticipant?.memberId
}))
}
async function mutateProposalStatus(
@@ -1007,6 +1065,22 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
.limit(1)
const senderMemberId = matchedMember[0]?.id ?? null
const householdMembers = (
await db
.select({
memberId: schema.members.id,
displayName: schema.members.displayName,
status: schema.members.lifecycleStatus
})
.from(schema.members)
.where(eq(schema.members.householdId, record.householdId))
)
.map((member) => ({
memberId: member.memberId,
displayName: member.displayName,
status: normalizeLifecycleStatus(member.status)
}))
.filter((member) => member.status !== 'left')
let parserError: string | null = null
const clarificationContext = interpreter ? await getClarificationContext(record) : undefined
@@ -1015,6 +1089,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
defaultCurrency: defaultCurrency ?? 'GEL',
householdContext: options?.householdContext ?? null,
assistantTone: options?.assistantTone ?? null,
householdMembers,
senderMemberId,
...(clarificationContext
? {
clarificationContext: {
@@ -1095,7 +1171,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
householdId: record.householdId,
senderTelegramUserId: record.senderTelegramUserId,
senderMemberId,
messageSentAt: record.messageSentAt
messageSentAt: record.messageSentAt,
explicitParticipantMemberIds: decision.participantMemberIds
})
if (participants.length > 0) {
@@ -1623,11 +1700,9 @@ async function routePurchaseTopicMessage(input: {
topicRole: 'purchase',
activeWorkflow,
messageText: input.record.rawText,
explicitMention:
stripExplicitBotMention(input.ctx) !== null ||
looksLikeDirectBotAddress(input.record.rawText),
explicitMention: stripExplicitBotMention(input.ctx) !== null,
replyToBot: isReplyToCurrentBot(input.ctx),
directBotAddress: looksLikeDirectBotAddress(input.record.rawText),
directBotAddress: false,
memoryStore: input.memoryStore ?? {
get() {
return { summary: null, turns: [] }
@@ -1642,7 +1717,7 @@ async function routePurchaseTopicMessage(input: {
locale: input.locale,
topicRole: 'purchase',
messageText: input.record.rawText,
isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress,
isExplicitMention: conversationContext.explicitMention,
isReplyToBot: conversationContext.replyToBot,
activeWorkflow,
engagementAssessment: conversationContext.engagement,
@@ -2078,6 +2153,9 @@ export function registerPurchaseTopicIngestion(
const result = await repository.save(record, options.interpreter, 'GEL')
if (result.status === 'ignored_not_purchase') {
if (route.route === 'purchase_followup') {
await repository.clearClarificationContext?.(record)
}
return await next()
}
await handlePurchaseMessageResult(
@@ -2227,6 +2305,9 @@ export function registerConfiguredPurchaseTopicIngestion(
}
)
if (result.status === 'ignored_not_purchase') {
if (route.route === 'purchase_followup') {
await repository.clearClarificationContext?.(record)
}
return await next()
}

View File

@@ -12,7 +12,7 @@ function successfulResponse(payload: unknown): Response {
}
describe('createOpenAiTopicMessageRouter', () => {
test('overrides purchase workflow routes for planning chatter', async () => {
test('does not override purchase routes for planning chatter', async () => {
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
expect(router).toBeDefined()
@@ -41,18 +41,18 @@ describe('createOpenAiTopicMessageRouter', () => {
})
expect(route).toMatchObject({
route: 'topic_helper',
helperKind: 'assistant',
route: 'purchase_candidate',
helperKind: 'purchase',
shouldStartTyping: true,
shouldClearWorkflow: false,
reason: 'planning_guard'
reason: 'llm_purchase_guess'
})
} finally {
globalThis.fetch = originalFetch
}
})
test('overrides purchase followups for meta references to prior context', async () => {
test('does not override purchase followups for meta references', async () => {
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
expect(router).toBeDefined()
@@ -81,11 +81,11 @@ describe('createOpenAiTopicMessageRouter', () => {
})
expect(route).toMatchObject({
route: 'topic_helper',
helperKind: 'assistant',
shouldStartTyping: true,
shouldClearWorkflow: true,
reason: 'context_reference'
route: 'purchase_followup',
helperKind: 'purchase',
shouldStartTyping: false,
shouldClearWorkflow: false,
reason: 'llm_followup_guess'
})
} finally {
globalThis.fetch = originalFetch

View File

@@ -79,25 +79,6 @@ 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|tomorrow|later)\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 CONTEXT_REFERENCE_PATTERN =
/\b(?:already said(?: above)?|said above|question above|do you have context|from the dialog(?:ue)?|based on the dialog(?:ue)?)\b|(?:^|[^\p{L}])(?:контекст(?:\s+диалога)?|у\s+тебя\s+есть\s+контекст(?:\s+диалога)?|основываясь\s+на\s+диалоге|я\s+уже\s+сказал(?:\s+выше)?|уже\s+сказал(?:\s+выше)?|вопрос\s+выше|вопрос\s+уже\s+есть|это\s+вопрос|ответь\s+на\s+него)(?=$|[^\p{L}])/iu
const CONTEXT_REFERENCE_STRIP_PATTERN = new RegExp(CONTEXT_REFERENCE_PATTERN.source, 'giu')
const LETTER_PATTERN = /\p{L}/u
const DIRECT_BOT_ADDRESS_PATTERN =
/^\s*(?:(?:ну|эй|слышь|слушай|hey|yo)\s*,?\s*)*(?:бот|bot)(?=$|[^\p{L}])/iu
export function looksLikeDirectBotAddress(text: string): boolean {
return DIRECT_BOT_ADDRESS_PATTERN.test(text.trim())
}
function normalizeRoute(value: string): TopicMessageRoute {
return value === 'chat_reply' ||
value === 'purchase_candidate' ||
@@ -127,103 +108,12 @@ function normalizeConfidence(value: number | null | undefined): number {
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."
}
function isBareContextReference(text: string): boolean {
const normalized = text.trim()
if (!CONTEXT_REFERENCE_PATTERN.test(normalized)) {
return false
}
const stripped = normalized
.replace(CONTEXT_REFERENCE_STRIP_PATTERN, ' ')
.replace(/[\s,.:;!?()[\]{}"'`-]+/gu, ' ')
.trim()
return stripped.length === 0
}
function isPlanningMessage(text: string): boolean {
const normalized = text.trim()
return PLANNING_PATTERN.test(normalized) && !LIKELY_PURCHASE_PATTERN.test(normalized)
}
function assistantFallbackRoute(
input: TopicMessageRoutingInput,
reason: string,
shouldClearWorkflow: boolean
): TopicMessageRoutingResult {
const shouldReply = input.isExplicitMention || input.isReplyToBot || input.activeWorkflow !== null
return shouldReply
? {
route: 'topic_helper',
replyText: null,
helperKind: 'assistant',
shouldStartTyping: true,
shouldClearWorkflow,
confidence: 88,
reason
}
: {
route: 'silent',
replyText: null,
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow,
confidence: 88,
reason
}
}
function applyRouteGuards(
input: TopicMessageRoutingInput,
route: TopicMessageRoutingResult
): TopicMessageRoutingResult {
const normalized = input.messageText.trim()
if (normalized.length === 0) {
return route
}
if (
isBareContextReference(normalized) &&
(route.route === 'purchase_candidate' ||
route.route === 'purchase_followup' ||
route.route === 'payment_candidate' ||
route.route === 'payment_followup')
) {
return assistantFallbackRoute(input, 'context_reference', input.activeWorkflow !== null)
}
if (
input.topicRole === 'purchase' &&
isPlanningMessage(normalized) &&
(route.route === 'purchase_candidate' || route.route === 'purchase_followup')
) {
return assistantFallbackRoute(input, 'planning_guard', input.activeWorkflow !== null)
}
return route
}
export function fallbackTopicMessageRoute(
input: TopicMessageRoutingInput
): TopicMessageRoutingResult {
const normalized = input.messageText.trim()
const isAddressed =
input.isExplicitMention || input.isReplyToBot || input.engagementAssessment?.engaged === true
if (normalized.length === 0 || !LETTER_PATTERN.test(normalized)) {
if (normalized.length === 0) {
return {
route: 'silent',
replyText: null,
@@ -235,27 +125,7 @@ export function fallbackTopicMessageRoute(
}
}
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 (isBareContextReference(normalized)) {
return assistantFallbackRoute(input, 'context_reference', input.activeWorkflow !== null)
}
if (input.topicRole === 'purchase') {
if (input.activeWorkflow === 'purchase_clarification' && isPlanningMessage(normalized)) {
return assistantFallbackRoute(input, 'planning_guard', true)
}
if (input.activeWorkflow === 'purchase_clarification') {
return {
route: 'purchase_followup',
@@ -267,33 +137,6 @@ export function fallbackTopicMessageRoute(
reason: 'active_purchase_workflow'
}
}
if (isAddressed && PLANNING_PATTERN.test(normalized)) {
return {
route: 'chat_reply',
replyText:
input.locale === 'ru'
? 'Похоже, ты пока прикидываешь. Когда захочешь мнение или реальную покупку записать, подключусь.'
: "Sounds like you're still thinking it through. If you want an opinion or a real purchase recorded, I'm in.",
helperKind: 'assistant',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 66,
reason: 'planning_advice'
}
}
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') {
@@ -311,18 +154,6 @@ export function fallbackTopicMessageRoute(
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 (
@@ -340,7 +171,7 @@ export function fallbackTopicMessageRoute(
}
}
if (isAddressed) {
if (input.isExplicitMention || input.isReplyToBot) {
return {
route: 'topic_helper',
replyText: null,
@@ -449,10 +280,15 @@ export function createOpenAiTopicMessageRouter(
'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.',
'Decide from context whether the user is actually addressing the bot, talking about the bot, or talking to another person.',
'Do not treat the mere presence of words like "bot", "hey", "listen", or "stop" as proof that the user is addressing the bot.',
'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.',
'For a bare summon like “bot?”, “pss bot”, or “ты тут?”, prefer a brief acknowledgment.',
'When the user directly addresses the bot with small talk, joking, or testing, prefer chat_reply with one short sentence.',
'In a purchase topic, if the user is discussing a possible future purchase and asks for an opinion, prefer chat_reply with a short contextual opinion instead of a workflow.',
'Do not repeatedly end casual replies with “how can I help?” unless the user is clearly asking for assistance.',
'For impossible or fantastical purchases and payments, stay playful and non-actionable unless the user clearly pivots back to a real household event.',
'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.',
@@ -479,7 +315,6 @@ export function createOpenAiTopicMessageRouter(
`Topic role: ${input.topicRole}`,
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
`Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? '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'}`
@@ -578,7 +413,7 @@ export function createOpenAiTopicMessageRouter(
? parsedObject.replyText.trim()
: null
return applyRouteGuards(input, {
return {
route,
replyText,
helperKind:
@@ -591,7 +426,7 @@ export function createOpenAiTopicMessageRouter(
typeof parsedObject.confidence === 'number' ? parsedObject.confidence : null
),
reason: typeof parsedObject.reason === 'string' ? parsedObject.reason : null
})
}
} catch {
return fallbackTopicMessageRoute(input)
} finally {