mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
Merge branch 'codex/topic-workflow-followups'
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }> = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 лари')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user