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('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(
|
||||||
await bot.handleUpdate(topicMessageUpdate('Bot, do you remember what you answered?') as never)
|
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('I think we need a TV in the house')
|
||||||
expect(recentThreadTexts).toContain('Yes. You were discussing a TV for 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)
|
||||||
await bot.handleUpdate(
|
await bot.handleUpdate(
|
||||||
topicMessageUpdate('Бот, можешь дать сводку, что происходило в чате?') as never
|
topicMentionUpdate('@household_test_bot можешь дать сводку, что происходило в чате?') as never
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(assistantCalls).toBe(1)
|
expect(assistantCalls).toBe(1)
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ import {
|
|||||||
type ConversationHistoryMessage
|
type ConversationHistoryMessage
|
||||||
} from './conversation-orchestrator'
|
} from './conversation-orchestrator'
|
||||||
import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router'
|
import type { TopicMessageRouter, TopicMessageRole } from './topic-message-router'
|
||||||
import {
|
import { fallbackTopicMessageRoute, getCachedTopicMessageRoute } from './topic-message-router'
|
||||||
fallbackTopicMessageRoute,
|
|
||||||
getCachedTopicMessageRoute,
|
|
||||||
looksLikeDirectBotAddress
|
|
||||||
} from './topic-message-router'
|
|
||||||
import {
|
import {
|
||||||
persistTopicHistoryMessage,
|
persistTopicHistoryMessage,
|
||||||
telegramMessageIdFromMessage,
|
telegramMessageIdFromMessage,
|
||||||
@@ -1311,10 +1307,7 @@ export function registerDmAssistant(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mention = stripExplicitBotMention(ctx)
|
const mention = stripExplicitBotMention(ctx)
|
||||||
const directAddressByText = looksLikeDirectBotAddress(ctx.msg.text)
|
const isExplicitMention = Boolean(mention && mention.strippedText.length > 0)
|
||||||
const isExplicitMention = Boolean(
|
|
||||||
(mention && mention.strippedText.length > 0) || directAddressByText
|
|
||||||
)
|
|
||||||
const isReplyToBot = isReplyToBotMessage(ctx)
|
const isReplyToBot = isReplyToBotMessage(ctx)
|
||||||
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
@@ -1417,7 +1410,7 @@ export function registerDmAssistant(options: {
|
|||||||
messageText,
|
messageText,
|
||||||
explicitMention: isExplicitMention,
|
explicitMention: isExplicitMention,
|
||||||
replyToBot: isReplyToBot,
|
replyToBot: isReplyToBot,
|
||||||
directBotAddress: directAddressByText,
|
directBotAddress: false,
|
||||||
memoryStore: options.memoryStore
|
memoryStore: options.memoryStore
|
||||||
})
|
})
|
||||||
const route =
|
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.',
|
'For unsupported writes, explain the limitation briefly and suggest the explicit command or confirmation flow.',
|
||||||
'Prefer concise, practical answers.',
|
'Prefer concise, practical answers.',
|
||||||
'Default to one to three short sentences.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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')
|
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
|
||||||
expect(interpreter).toBeDefined()
|
expect(interpreter).toBeDefined()
|
||||||
|
|
||||||
@@ -40,7 +40,27 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
|||||||
let fetchCalls = 0
|
let fetchCalls = 0
|
||||||
globalThis.fetch = (async () => {
|
globalThis.fetch = (async () => {
|
||||||
fetchCalls += 1
|
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
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -59,13 +79,13 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
|||||||
parserMode: 'llm',
|
parserMode: 'llm',
|
||||||
clarificationQuestion: null
|
clarificationQuestion: null
|
||||||
})
|
})
|
||||||
expect(fetchCalls).toBe(0)
|
expect(fetchCalls).toBe(1)
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
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')
|
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
|
||||||
expect(interpreter).toBeDefined()
|
expect(interpreter).toBeDefined()
|
||||||
|
|
||||||
@@ -73,7 +93,27 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
|||||||
let fetchCalls = 0
|
let fetchCalls = 0
|
||||||
globalThis.fetch = (async () => {
|
globalThis.fetch = (async () => {
|
||||||
fetchCalls += 1
|
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
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +132,7 @@ describe('createOpenAiPurchaseInterpreter', () => {
|
|||||||
parserMode: 'llm',
|
parserMode: 'llm',
|
||||||
clarificationQuestion: null
|
clarificationQuestion: null
|
||||||
})
|
})
|
||||||
expect(fetchCalls).toBe(0)
|
expect(fetchCalls).toBe(1)
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
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 () => {
|
test('parses nested responses api content output', async () => {
|
||||||
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
|
const interpreter = createOpenAiPurchaseInterpreter('test-key', 'gpt-5-mini')
|
||||||
expect(interpreter).toBeDefined()
|
expect(interpreter).toBeDefined()
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-r
|
|||||||
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
|
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
|
||||||
export type PurchaseInterpretationAmountSource = 'explicit' | 'calculated'
|
export type PurchaseInterpretationAmountSource = 'explicit' | 'calculated'
|
||||||
|
|
||||||
|
export interface PurchaseInterpreterHouseholdMember {
|
||||||
|
memberId: string
|
||||||
|
displayName: string
|
||||||
|
status: 'active' | 'away' | 'left'
|
||||||
|
}
|
||||||
|
|
||||||
export interface PurchaseInterpretation {
|
export interface PurchaseInterpretation {
|
||||||
decision: PurchaseInterpretationDecision
|
decision: PurchaseInterpretationDecision
|
||||||
amountMinor: bigint | null
|
amountMinor: bigint | null
|
||||||
@@ -10,6 +16,7 @@ export interface PurchaseInterpretation {
|
|||||||
itemDescription: string | null
|
itemDescription: string | null
|
||||||
amountSource?: PurchaseInterpretationAmountSource | null
|
amountSource?: PurchaseInterpretationAmountSource | null
|
||||||
calculationExplanation?: string | null
|
calculationExplanation?: string | null
|
||||||
|
participantMemberIds?: readonly string[] | null
|
||||||
confidence: number
|
confidence: number
|
||||||
parserMode: 'llm'
|
parserMode: 'llm'
|
||||||
clarificationQuestion: string | null
|
clarificationQuestion: string | null
|
||||||
@@ -26,6 +33,8 @@ export type PurchaseMessageInterpreter = (
|
|||||||
clarificationContext?: PurchaseClarificationContext
|
clarificationContext?: PurchaseClarificationContext
|
||||||
householdContext?: string | null
|
householdContext?: string | null
|
||||||
assistantTone?: string | null
|
assistantTone?: string | null
|
||||||
|
householdMembers?: readonly PurchaseInterpreterHouseholdMember[]
|
||||||
|
senderMemberId?: string | null
|
||||||
}
|
}
|
||||||
) => Promise<PurchaseInterpretation | null>
|
) => Promise<PurchaseInterpretation | null>
|
||||||
|
|
||||||
@@ -36,18 +45,11 @@ interface OpenAiStructuredResult {
|
|||||||
itemDescription: string | null
|
itemDescription: string | null
|
||||||
amountSource: PurchaseInterpretationAmountSource | null
|
amountSource: PurchaseInterpretationAmountSource | null
|
||||||
calculationExplanation: string | null
|
calculationExplanation: string | null
|
||||||
|
participantMemberIds: string[] | null
|
||||||
confidence: number
|
confidence: number
|
||||||
clarificationQuestion: string | null
|
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 {
|
function asOptionalBigInt(value: string | null): bigint | null {
|
||||||
if (value === null || !/^[0-9]+$/.test(value)) {
|
if (value === null || !/^[0-9]+$/.test(value)) {
|
||||||
return null
|
return null
|
||||||
@@ -82,6 +84,26 @@ function normalizeConfidence(value: number): number {
|
|||||||
return Math.max(0, Math.min(100, Math.round(scaled)))
|
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: {
|
function resolveMissingCurrency(input: {
|
||||||
decision: PurchaseInterpretationDecision
|
decision: PurchaseInterpretationDecision
|
||||||
amountMinor: bigint | null
|
amountMinor: bigint | null
|
||||||
@@ -125,33 +147,6 @@ export function buildPurchaseInterpretationInput(
|
|||||||
].join('\n')
|
].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(
|
export function createOpenAiPurchaseInterpreter(
|
||||||
apiKey: string | undefined,
|
apiKey: string | undefined,
|
||||||
model: string
|
model: string
|
||||||
@@ -161,7 +156,7 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return async (rawText, options) => {
|
return async (rawText, options) => {
|
||||||
if (shouldReturnNotPurchase(rawText)) {
|
if (rawText.trim().length === 0) {
|
||||||
return {
|
return {
|
||||||
decision: 'not_purchase',
|
decision: 'not_purchase',
|
||||||
amountMinor: null,
|
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.',
|
'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".',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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 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 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 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.',
|
'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.',
|
'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.',
|
'The clarification should sound like a conversational household bot, not a form validator.',
|
||||||
@@ -217,7 +217,20 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
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: {
|
text: {
|
||||||
@@ -259,6 +272,15 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
calculationExplanation: {
|
calculationExplanation: {
|
||||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||||
},
|
},
|
||||||
|
participantMemberIds: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' }
|
||||||
|
},
|
||||||
|
{ type: 'null' }
|
||||||
|
]
|
||||||
|
},
|
||||||
confidence: {
|
confidence: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
@@ -275,6 +297,7 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
'itemDescription',
|
'itemDescription',
|
||||||
'amountSource',
|
'amountSource',
|
||||||
'calculationExplanation',
|
'calculationExplanation',
|
||||||
|
'participantMemberIds',
|
||||||
'confidence',
|
'confidence',
|
||||||
'clarificationQuestion'
|
'clarificationQuestion'
|
||||||
]
|
]
|
||||||
@@ -318,6 +341,10 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
||||||
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
|
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
|
||||||
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
|
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
|
||||||
|
const participantMemberIds = normalizeParticipantMemberIds(
|
||||||
|
parsedJson.participantMemberIds,
|
||||||
|
options.householdMembers
|
||||||
|
)
|
||||||
const currency = resolveMissingCurrency({
|
const currency = resolveMissingCurrency({
|
||||||
decision: parsedJson.decision,
|
decision: parsedJson.decision,
|
||||||
amountMinor,
|
amountMinor,
|
||||||
@@ -337,7 +364,7 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result: PurchaseInterpretation = {
|
||||||
decision,
|
decision,
|
||||||
amountMinor,
|
amountMinor,
|
||||||
currency,
|
currency,
|
||||||
@@ -348,5 +375,11 @@ export function createOpenAiPurchaseInterpreter(
|
|||||||
parserMode: 'llm',
|
parserMode: 'llm',
|
||||||
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
|
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 () => {
|
test('confirms a pending payment proposal from a topic callback', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
cacheTopicMessageRoute,
|
cacheTopicMessageRoute,
|
||||||
getCachedTopicMessageRoute,
|
getCachedTopicMessageRoute,
|
||||||
looksLikeDirectBotAddress,
|
|
||||||
type TopicMessageRouter
|
type TopicMessageRouter
|
||||||
} from './topic-message-router'
|
} from './topic-message-router'
|
||||||
import {
|
import {
|
||||||
@@ -285,9 +284,9 @@ async function routePaymentTopicMessage(input: {
|
|||||||
topicRole: input.topicRole,
|
topicRole: input.topicRole,
|
||||||
activeWorkflow: input.activeWorkflow,
|
activeWorkflow: input.activeWorkflow,
|
||||||
messageText: input.record.rawText,
|
messageText: input.record.rawText,
|
||||||
explicitMention: input.isExplicitMention || looksLikeDirectBotAddress(input.record.rawText),
|
explicitMention: input.isExplicitMention,
|
||||||
replyToBot: input.isReplyToBot,
|
replyToBot: input.isReplyToBot,
|
||||||
directBotAddress: looksLikeDirectBotAddress(input.record.rawText),
|
directBotAddress: false,
|
||||||
memoryStore: input.memoryStore ?? {
|
memoryStore: input.memoryStore ?? {
|
||||||
get() {
|
get() {
|
||||||
return { summary: null, turns: [] }
|
return { summary: null, turns: [] }
|
||||||
@@ -302,7 +301,7 @@ async function routePaymentTopicMessage(input: {
|
|||||||
locale: input.locale,
|
locale: input.locale,
|
||||||
topicRole: input.topicRole,
|
topicRole: input.topicRole,
|
||||||
messageText: input.record.rawText,
|
messageText: input.record.rawText,
|
||||||
isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress,
|
isExplicitMention: conversationContext.explicitMention,
|
||||||
isReplyToBot: conversationContext.replyToBot,
|
isReplyToBot: conversationContext.replyToBot,
|
||||||
activeWorkflow: input.activeWorkflow,
|
activeWorkflow: input.activeWorkflow,
|
||||||
engagementAssessment: conversationContext.engagement,
|
engagementAssessment: conversationContext.engagement,
|
||||||
@@ -742,6 +741,9 @@ export function registerConfiguredPaymentTopicIngestion(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (proposal.status === 'no_intent') {
|
if (proposal.status === 'no_intent') {
|
||||||
|
if (route.route === 'payment_followup') {
|
||||||
|
await promptRepository.clearPendingAction(record.chatId, record.senderTelegramUserId)
|
||||||
|
}
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
extractPurchaseTopicCandidate,
|
extractPurchaseTopicCandidate,
|
||||||
registerConfiguredPurchaseTopicIngestion,
|
registerConfiguredPurchaseTopicIngestion,
|
||||||
registerPurchaseTopicIngestion,
|
registerPurchaseTopicIngestion,
|
||||||
|
resolveProposalParticipantSelection,
|
||||||
resolveConfiguredPurchaseTopicRecord,
|
resolveConfiguredPurchaseTopicRecord,
|
||||||
type PurchaseMessageIngestionRepository,
|
type PurchaseMessageIngestionRepository,
|
||||||
type PurchaseTopicCandidate
|
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', () => {
|
describe('registerPurchaseTopicIngestion', () => {
|
||||||
test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
|
test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
|
||||||
const bot = createTestBot()
|
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 () => {
|
test('continues purchase handling for replies to bot messages without a fresh mention', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
@@ -1765,7 +1906,7 @@ Confirm or cancel below.`,
|
|||||||
await bot.handleUpdate(
|
await bot.handleUpdate(
|
||||||
purchaseUpdate('В общем, думаю купить 5 килограмм картошки за 20 лари') as never
|
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(sawDirectAddress).toBe(true)
|
||||||
expect(recentTurnTexts).toContain('В общем, думаю купить 5 килограмм картошки за 20 лари')
|
expect(recentTurnTexts).toContain('В общем, думаю купить 5 килограмм картошки за 20 лари')
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import type {
|
|||||||
import {
|
import {
|
||||||
cacheTopicMessageRoute,
|
cacheTopicMessageRoute,
|
||||||
getCachedTopicMessageRoute,
|
getCachedTopicMessageRoute,
|
||||||
looksLikeDirectBotAddress,
|
|
||||||
type TopicMessageRouter,
|
type TopicMessageRouter,
|
||||||
type TopicMessageRoutingResult
|
type TopicMessageRoutingResult
|
||||||
} from './topic-message-router'
|
} from './topic-message-router'
|
||||||
@@ -245,6 +244,7 @@ interface PurchasePersistenceDecision {
|
|||||||
parsedItemDescription: string | null
|
parsedItemDescription: string | null
|
||||||
amountSource: PurchaseInterpretationAmountSource | null
|
amountSource: PurchaseInterpretationAmountSource | null
|
||||||
calculationExplanation: string | null
|
calculationExplanation: string | null
|
||||||
|
participantMemberIds: readonly string[] | null
|
||||||
parserConfidence: number | null
|
parserConfidence: number | null
|
||||||
parserMode: 'llm' | null
|
parserMode: 'llm' | null
|
||||||
clarificationQuestion: string | null
|
clarificationQuestion: string | null
|
||||||
@@ -306,6 +306,7 @@ function normalizeInterpretation(
|
|||||||
parsedItemDescription: null,
|
parsedItemDescription: null,
|
||||||
amountSource: null,
|
amountSource: null,
|
||||||
calculationExplanation: null,
|
calculationExplanation: null,
|
||||||
|
participantMemberIds: null,
|
||||||
parserConfidence: null,
|
parserConfidence: null,
|
||||||
parserMode: null,
|
parserMode: null,
|
||||||
clarificationQuestion: null,
|
clarificationQuestion: null,
|
||||||
@@ -322,6 +323,7 @@ function normalizeInterpretation(
|
|||||||
parsedItemDescription: interpretation.itemDescription,
|
parsedItemDescription: interpretation.itemDescription,
|
||||||
amountSource: interpretation.amountSource ?? null,
|
amountSource: interpretation.amountSource ?? null,
|
||||||
calculationExplanation: interpretation.calculationExplanation ?? null,
|
calculationExplanation: interpretation.calculationExplanation ?? null,
|
||||||
|
participantMemberIds: interpretation.participantMemberIds ?? null,
|
||||||
parserConfidence: interpretation.confidence,
|
parserConfidence: interpretation.confidence,
|
||||||
parserMode: interpretation.parserMode,
|
parserMode: interpretation.parserMode,
|
||||||
clarificationQuestion: null,
|
clarificationQuestion: null,
|
||||||
@@ -347,6 +349,7 @@ function normalizeInterpretation(
|
|||||||
parsedItemDescription: interpretation.itemDescription,
|
parsedItemDescription: interpretation.itemDescription,
|
||||||
amountSource: interpretation.amountSource ?? null,
|
amountSource: interpretation.amountSource ?? null,
|
||||||
calculationExplanation: interpretation.calculationExplanation ?? null,
|
calculationExplanation: interpretation.calculationExplanation ?? null,
|
||||||
|
participantMemberIds: interpretation.participantMemberIds ?? null,
|
||||||
parserConfidence: interpretation.confidence,
|
parserConfidence: interpretation.confidence,
|
||||||
parserMode: interpretation.parserMode,
|
parserMode: interpretation.parserMode,
|
||||||
clarificationQuestion: interpretation.clarificationQuestion,
|
clarificationQuestion: interpretation.clarificationQuestion,
|
||||||
@@ -362,6 +365,7 @@ function normalizeInterpretation(
|
|||||||
parsedItemDescription: interpretation.itemDescription,
|
parsedItemDescription: interpretation.itemDescription,
|
||||||
amountSource: interpretation.amountSource ?? null,
|
amountSource: interpretation.amountSource ?? null,
|
||||||
calculationExplanation: interpretation.calculationExplanation ?? null,
|
calculationExplanation: interpretation.calculationExplanation ?? null,
|
||||||
|
participantMemberIds: interpretation.participantMemberIds ?? null,
|
||||||
parserConfidence: interpretation.confidence,
|
parserConfidence: interpretation.confidence,
|
||||||
parserMode: interpretation.parserMode,
|
parserMode: interpretation.parserMode,
|
||||||
clarificationQuestion: null,
|
clarificationQuestion: null,
|
||||||
@@ -378,6 +382,86 @@ function participantIncludedAsInt(value: boolean): number {
|
|||||||
return value ? 1 : 0
|
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: {
|
function toStoredPurchaseRow(row: {
|
||||||
id: string
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -779,6 +863,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
senderTelegramUserId: string
|
senderTelegramUserId: string
|
||||||
senderMemberId: string | null
|
senderMemberId: string | null
|
||||||
messageSentAt: Instant
|
messageSentAt: Instant
|
||||||
|
explicitParticipantMemberIds: readonly string[] | null
|
||||||
}): Promise<readonly { memberId: string; included: boolean }[]> {
|
}): Promise<readonly { memberId: string; included: boolean }[]> {
|
||||||
const [members, settingsRows, policyRows] = await Promise.all([
|
const [members, settingsRows, policyRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
@@ -830,44 +915,17 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const participants = members
|
return resolveProposalParticipantSelection({
|
||||||
.filter((member) => member.lifecycleStatus !== 'left')
|
members: members.map((member) => ({
|
||||||
.map((member) => {
|
memberId: member.id,
|
||||||
const policy = policyByMemberId.get(member.id)?.policy ?? 'resident'
|
telegramUserId: member.telegramUserId,
|
||||||
const included =
|
lifecycleStatus: normalizeLifecycleStatus(member.lifecycleStatus)
|
||||||
member.lifecycleStatus === 'away'
|
})),
|
||||||
? policy === 'resident'
|
policyByMemberId,
|
||||||
: member.lifecycleStatus === 'active'
|
senderTelegramUserId: input.senderTelegramUserId,
|
||||||
|
senderMemberId: input.senderMemberId,
|
||||||
return {
|
explicitParticipantMemberIds: input.explicitParticipantMemberIds
|
||||||
memberId: member.id,
|
})
|
||||||
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
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutateProposalStatus(
|
async function mutateProposalStatus(
|
||||||
@@ -1007,6 +1065,22 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
const senderMemberId = matchedMember[0]?.id ?? null
|
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
|
let parserError: string | null = null
|
||||||
const clarificationContext = interpreter ? await getClarificationContext(record) : undefined
|
const clarificationContext = interpreter ? await getClarificationContext(record) : undefined
|
||||||
|
|
||||||
@@ -1015,6 +1089,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
defaultCurrency: defaultCurrency ?? 'GEL',
|
defaultCurrency: defaultCurrency ?? 'GEL',
|
||||||
householdContext: options?.householdContext ?? null,
|
householdContext: options?.householdContext ?? null,
|
||||||
assistantTone: options?.assistantTone ?? null,
|
assistantTone: options?.assistantTone ?? null,
|
||||||
|
householdMembers,
|
||||||
|
senderMemberId,
|
||||||
...(clarificationContext
|
...(clarificationContext
|
||||||
? {
|
? {
|
||||||
clarificationContext: {
|
clarificationContext: {
|
||||||
@@ -1095,7 +1171,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
householdId: record.householdId,
|
householdId: record.householdId,
|
||||||
senderTelegramUserId: record.senderTelegramUserId,
|
senderTelegramUserId: record.senderTelegramUserId,
|
||||||
senderMemberId,
|
senderMemberId,
|
||||||
messageSentAt: record.messageSentAt
|
messageSentAt: record.messageSentAt,
|
||||||
|
explicitParticipantMemberIds: decision.participantMemberIds
|
||||||
})
|
})
|
||||||
|
|
||||||
if (participants.length > 0) {
|
if (participants.length > 0) {
|
||||||
@@ -1623,11 +1700,9 @@ async function routePurchaseTopicMessage(input: {
|
|||||||
topicRole: 'purchase',
|
topicRole: 'purchase',
|
||||||
activeWorkflow,
|
activeWorkflow,
|
||||||
messageText: input.record.rawText,
|
messageText: input.record.rawText,
|
||||||
explicitMention:
|
explicitMention: stripExplicitBotMention(input.ctx) !== null,
|
||||||
stripExplicitBotMention(input.ctx) !== null ||
|
|
||||||
looksLikeDirectBotAddress(input.record.rawText),
|
|
||||||
replyToBot: isReplyToCurrentBot(input.ctx),
|
replyToBot: isReplyToCurrentBot(input.ctx),
|
||||||
directBotAddress: looksLikeDirectBotAddress(input.record.rawText),
|
directBotAddress: false,
|
||||||
memoryStore: input.memoryStore ?? {
|
memoryStore: input.memoryStore ?? {
|
||||||
get() {
|
get() {
|
||||||
return { summary: null, turns: [] }
|
return { summary: null, turns: [] }
|
||||||
@@ -1642,7 +1717,7 @@ async function routePurchaseTopicMessage(input: {
|
|||||||
locale: input.locale,
|
locale: input.locale,
|
||||||
topicRole: 'purchase',
|
topicRole: 'purchase',
|
||||||
messageText: input.record.rawText,
|
messageText: input.record.rawText,
|
||||||
isExplicitMention: conversationContext.explicitMention || conversationContext.directBotAddress,
|
isExplicitMention: conversationContext.explicitMention,
|
||||||
isReplyToBot: conversationContext.replyToBot,
|
isReplyToBot: conversationContext.replyToBot,
|
||||||
activeWorkflow,
|
activeWorkflow,
|
||||||
engagementAssessment: conversationContext.engagement,
|
engagementAssessment: conversationContext.engagement,
|
||||||
@@ -2078,6 +2153,9 @@ export function registerPurchaseTopicIngestion(
|
|||||||
const result = await repository.save(record, options.interpreter, 'GEL')
|
const result = await repository.save(record, options.interpreter, 'GEL')
|
||||||
|
|
||||||
if (result.status === 'ignored_not_purchase') {
|
if (result.status === 'ignored_not_purchase') {
|
||||||
|
if (route.route === 'purchase_followup') {
|
||||||
|
await repository.clearClarificationContext?.(record)
|
||||||
|
}
|
||||||
return await next()
|
return await next()
|
||||||
}
|
}
|
||||||
await handlePurchaseMessageResult(
|
await handlePurchaseMessageResult(
|
||||||
@@ -2227,6 +2305,9 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (result.status === 'ignored_not_purchase') {
|
if (result.status === 'ignored_not_purchase') {
|
||||||
|
if (route.route === 'purchase_followup') {
|
||||||
|
await repository.clearClarificationContext?.(record)
|
||||||
|
}
|
||||||
return await next()
|
return await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function successfulResponse(payload: unknown): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('createOpenAiTopicMessageRouter', () => {
|
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)
|
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
|
||||||
expect(router).toBeDefined()
|
expect(router).toBeDefined()
|
||||||
|
|
||||||
@@ -41,18 +41,18 @@ describe('createOpenAiTopicMessageRouter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(route).toMatchObject({
|
expect(route).toMatchObject({
|
||||||
route: 'topic_helper',
|
route: 'purchase_candidate',
|
||||||
helperKind: 'assistant',
|
helperKind: 'purchase',
|
||||||
shouldStartTyping: true,
|
shouldStartTyping: true,
|
||||||
shouldClearWorkflow: false,
|
shouldClearWorkflow: false,
|
||||||
reason: 'planning_guard'
|
reason: 'llm_purchase_guess'
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
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)
|
const router = createOpenAiTopicMessageRouter('test-key', 'gpt-5-mini', 20_000)
|
||||||
expect(router).toBeDefined()
|
expect(router).toBeDefined()
|
||||||
|
|
||||||
@@ -81,11 +81,11 @@ describe('createOpenAiTopicMessageRouter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(route).toMatchObject({
|
expect(route).toMatchObject({
|
||||||
route: 'topic_helper',
|
route: 'purchase_followup',
|
||||||
helperKind: 'assistant',
|
helperKind: 'purchase',
|
||||||
shouldStartTyping: true,
|
shouldStartTyping: false,
|
||||||
shouldClearWorkflow: true,
|
shouldClearWorkflow: false,
|
||||||
reason: 'context_reference'
|
reason: 'llm_followup_guess'
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch
|
||||||
|
|||||||
@@ -79,25 +79,6 @@ type ContextWithTopicMessageRouteCache = Context & {
|
|||||||
[topicMessageRouteCacheKey]?: TopicMessageRouteCacheEntry
|
[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 {
|
function normalizeRoute(value: string): TopicMessageRoute {
|
||||||
return value === 'chat_reply' ||
|
return value === 'chat_reply' ||
|
||||||
value === 'purchase_candidate' ||
|
value === 'purchase_candidate' ||
|
||||||
@@ -127,103 +108,12 @@ function normalizeConfidence(value: number | null | undefined): number {
|
|||||||
return Math.max(0, Math.min(100, Math.round(value)))
|
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(
|
export function fallbackTopicMessageRoute(
|
||||||
input: TopicMessageRoutingInput
|
input: TopicMessageRoutingInput
|
||||||
): TopicMessageRoutingResult {
|
): TopicMessageRoutingResult {
|
||||||
const normalized = input.messageText.trim()
|
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 {
|
return {
|
||||||
route: 'silent',
|
route: 'silent',
|
||||||
replyText: null,
|
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.topicRole === 'purchase') {
|
||||||
if (input.activeWorkflow === 'purchase_clarification' && isPlanningMessage(normalized)) {
|
|
||||||
return assistantFallbackRoute(input, 'planning_guard', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.activeWorkflow === 'purchase_clarification') {
|
if (input.activeWorkflow === 'purchase_clarification') {
|
||||||
return {
|
return {
|
||||||
route: 'purchase_followup',
|
route: 'purchase_followup',
|
||||||
@@ -267,33 +137,6 @@ export function fallbackTopicMessageRoute(
|
|||||||
reason: 'active_purchase_workflow'
|
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') {
|
if (input.topicRole === 'payments') {
|
||||||
@@ -311,18 +154,6 @@ export function fallbackTopicMessageRoute(
|
|||||||
reason: 'active_payment_workflow'
|
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 (
|
if (
|
||||||
@@ -340,7 +171,7 @@ export function fallbackTopicMessageRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAddressed) {
|
if (input.isExplicitMention || input.isReplyToBot) {
|
||||||
return {
|
return {
|
||||||
route: 'topic_helper',
|
route: 'topic_helper',
|
||||||
replyText: null,
|
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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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}`,
|
`Topic role: ${input.topicRole}`,
|
||||||
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
|
`Explicit mention: ${input.isExplicitMention ? 'yes' : 'no'}`,
|
||||||
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
|
`Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`,
|
||||||
`Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`,
|
|
||||||
`Active workflow: ${input.activeWorkflow ?? 'none'}`,
|
`Active workflow: ${input.activeWorkflow ?? 'none'}`,
|
||||||
input.engagementAssessment
|
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'}`
|
? `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()
|
? parsedObject.replyText.trim()
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return applyRouteGuards(input, {
|
return {
|
||||||
route,
|
route,
|
||||||
replyText,
|
replyText,
|
||||||
helperKind:
|
helperKind:
|
||||||
@@ -591,7 +426,7 @@ export function createOpenAiTopicMessageRouter(
|
|||||||
typeof parsedObject.confidence === 'number' ? parsedObject.confidence : null
|
typeof parsedObject.confidence === 'number' ? parsedObject.confidence : null
|
||||||
),
|
),
|
||||||
reason: typeof parsedObject.reason === 'string' ? parsedObject.reason : null
|
reason: typeof parsedObject.reason === 'string' ? parsedObject.reason : null
|
||||||
})
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return fallbackTopicMessageRoute(input)
|
return fallbackTopicMessageRoute(input)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user