feat(bot): add configurable household assistant behavior

This commit is contained in:
2026-03-12 03:22:43 +04:00
parent 146f5294f4
commit 4e7400e908
22 changed files with 4127 additions and 96 deletions

View File

@@ -59,7 +59,12 @@ function privateMessageUpdate(text: string) {
} }
} }
function topicMentionUpdate(text: string) { function topicMessageUpdate(
text: string,
options?: {
replyToBot?: boolean
}
) {
return { return {
update_id: 3001, update_id: 3001,
message: { message: {
@@ -77,9 +82,32 @@ function topicMentionUpdate(text: string) {
first_name: 'Stan', first_name: 'Stan',
language_code: 'en' language_code: 'en'
}, },
text text,
...(options?.replyToBot
? {
reply_to_message: {
message_id: 87,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123,
type: 'supergroup'
},
from: {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot'
},
text: 'previous bot reply'
} }
} }
: {})
}
}
}
function topicMentionUpdate(text: string) {
return topicMessageUpdate(text)
} }
function privateCallbackUpdate(data: string) { function privateCallbackUpdate(data: string) {
@@ -1212,6 +1240,241 @@ Confirm or cancel below.`,
}) })
}) })
test('stays silent for regular group chatter when the bot is not addressed', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let assistantCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123,
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
registerDmAssistant({
bot,
assistant: {
async respond() {
assistantCalls += 1
return {
text: 'I should not speak here.',
usage: {
inputTokens: 12,
outputTokens: 5,
totalTokens: 17
}
}
}
},
purchaseRepository: createPurchaseRepository(),
purchaseInterpreter: async () => null,
householdConfigurationRepository: createHouseholdRepository(),
promptRepository: createPromptRepository(),
financeServiceForHousehold: () => createFinanceService(),
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
rateLimiter: createInMemoryAssistantRateLimiter({
burstLimit: 5,
burstWindowMs: 60_000,
rollingLimit: 50,
rollingWindowMs: 86_400_000
}),
usageTracker: createInMemoryAssistantUsageTracker()
})
await bot.handleUpdate(topicMessageUpdate('Dima is joking with Stas again') as never)
expect(assistantCalls).toBe(0)
expect(calls).toHaveLength(0)
})
test('creates a purchase proposal in a household topic without an explicit mention', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123,
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
registerDmAssistant({
bot,
assistant: {
async respond() {
return {
text: 'fallback',
usage: {
inputTokens: 10,
outputTokens: 2,
totalTokens: 12
}
}
}
},
purchaseRepository: createPurchaseRepository(),
purchaseInterpreter: async () => null,
householdConfigurationRepository: createHouseholdRepository(),
promptRepository: createPromptRepository(),
financeServiceForHousehold: () => createFinanceService(),
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
rateLimiter: createInMemoryAssistantRateLimiter({
burstLimit: 5,
burstWindowMs: 60_000,
rollingLimit: 50,
rollingWindowMs: 86_400_000
}),
usageTracker: createInMemoryAssistantUsageTracker()
})
await bot.handleUpdate(topicMessageUpdate('I bought a door handle for 30 lari') as never)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: -100123,
message_thread_id: 777,
text: expect.stringContaining('door handle - 30.00 GEL'),
reply_markup: {
inline_keyboard: [
[
{
text: 'Confirm',
callback_data: 'assistant_purchase:confirm:purchase-1'
},
{
text: 'Cancel',
callback_data: 'assistant_purchase:cancel:purchase-1'
}
]
]
}
}
})
})
test('replies when a household member answers the bot message in a topic', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let assistantCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123,
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
registerDmAssistant({
bot,
assistant: {
async respond(input) {
assistantCalls += 1
expect(input.userMessage).toBe('tell me a joke')
return {
text: 'Rent is still due on the 20th.',
usage: {
inputTokens: 17,
outputTokens: 8,
totalTokens: 25
}
}
}
},
purchaseRepository: createPurchaseRepository(),
purchaseInterpreter: async () => null,
householdConfigurationRepository: createHouseholdRepository(),
promptRepository: createPromptRepository(),
financeServiceForHousehold: () => createFinanceService(),
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
rateLimiter: createInMemoryAssistantRateLimiter({
burstLimit: 5,
burstWindowMs: 60_000,
rollingLimit: 50,
rollingWindowMs: 86_400_000
}),
usageTracker: createInMemoryAssistantUsageTracker()
})
await bot.handleUpdate(
topicMessageUpdate('tell me a joke', {
replyToBot: true
}) as never
)
expect(assistantCalls).toBe(1)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'sendChatAction',
payload: {
chat_id: -100123,
action: 'typing',
message_thread_id: 777
}
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: -100123,
message_thread_id: 777,
text: 'Rent is still due on the 20th.'
}
})
})
test('ignores duplicate deliveries of the same DM update', async () => { test('ignores duplicate deliveries of the same DM update', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -123,6 +123,15 @@ function isCommandMessage(ctx: Context): boolean {
return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/') return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/')
} }
function isReplyToBotMessage(ctx: Context): boolean {
const replyAuthor = ctx.msg?.reply_to_message?.from
if (!replyAuthor) {
return false
}
return replyAuthor.id === ctx.me.id
}
function summarizeTurns( function summarizeTurns(
summary: string | null, summary: string | null,
turns: readonly AssistantConversationTurn[] turns: readonly AssistantConversationTurn[]
@@ -403,6 +412,44 @@ function createDmPurchaseRecord(ctx: Context, householdId: string): PurchaseTopi
} }
} }
function createGroupPurchaseRecord(
ctx: Context,
householdId: string,
rawText: string
): PurchaseTopicRecord | null {
if (!isGroupChat(ctx) || !ctx.msg || !ctx.from) {
return null
}
const normalized = rawText.trim()
if (normalized.length === 0) {
return null
}
const senderDisplayName = [ctx.from.first_name, ctx.from.last_name]
.filter((part) => !!part && part.trim().length > 0)
.join(' ')
return {
updateId: ctx.update.update_id,
householdId,
chatId: ctx.chat!.id.toString(),
messageId: ctx.msg.message_id.toString(),
threadId:
'message_thread_id' in ctx.msg && ctx.msg.message_thread_id !== undefined
? ctx.msg.message_thread_id.toString()
: ctx.chat!.id.toString(),
senderTelegramUserId: ctx.from.id.toString(),
rawText: normalized,
messageSentAt: instantFromEpochSeconds(ctx.msg.date),
...(senderDisplayName.length > 0
? {
senderDisplayName
}
: {})
}
}
function looksLikePurchaseIntent(rawText: string): boolean { function looksLikePurchaseIntent(rawText: string): boolean {
const normalized = rawText.trim() const normalized = rawText.trim()
if (normalized.length === 0) { if (normalized.length === 0) {
@@ -416,6 +463,19 @@ function looksLikePurchaseIntent(rawText: string): boolean {
return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized) return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized)
} }
async function resolveAssistantConfig(
householdConfigurationRepository: HouseholdConfigurationRepository,
householdId: string
) {
return householdConfigurationRepository.getHouseholdAssistantConfig
? await householdConfigurationRepository.getHouseholdAssistantConfig(householdId)
: {
householdId,
assistantContext: null,
assistantTone: null
}
}
function formatAssistantLedger( function formatAssistantLedger(
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>> dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
) { ) {
@@ -440,9 +500,10 @@ async function buildHouseholdContext(input: {
householdConfigurationRepository: HouseholdConfigurationRepository householdConfigurationRepository: HouseholdConfigurationRepository
financeService: FinanceCommandService financeService: FinanceCommandService
}): Promise<string> { }): Promise<string> {
const [household, settings, dashboard, members] = await Promise.all([ const [household, settings, assistantConfig, dashboard, members] = await Promise.all([
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId),
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId), input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
resolveAssistantConfig(input.householdConfigurationRepository, input.householdId),
input.financeService.generateDashboard(), input.financeService.generateDashboard(),
input.householdConfigurationRepository.listHouseholdMembers(input.householdId) input.householdConfigurationRepository.listHouseholdMembers(input.householdId)
]) ])
@@ -456,6 +517,14 @@ async function buildHouseholdContext(input: {
`Current billing cycle: ${dashboard?.period ?? 'not available'}` `Current billing cycle: ${dashboard?.period ?? 'not available'}`
] ]
if (assistantConfig.assistantTone) {
lines.push(`Preferred assistant tone: ${assistantConfig.assistantTone}`)
}
if (assistantConfig.assistantContext) {
lines.push(`Household narrative context: ${assistantConfig.assistantContext}`)
}
if (!dashboard) { if (!dashboard) {
lines.push('No current dashboard data is available yet.') lines.push('No current dashboard data is available yet.')
return lines.join('\n') return lines.join('\n')
@@ -988,14 +1057,20 @@ export function registerDmAssistant(options: {
const typingIndicator = startTypingIndicator(ctx) const typingIndicator = startTypingIndicator(ctx)
try { try {
const settings = const [settings, assistantConfig] = await Promise.all([
await options.householdConfigurationRepository.getHouseholdBillingSettings( options.householdConfigurationRepository.getHouseholdBillingSettings(
member.householdId member.householdId
) ),
resolveAssistantConfig(options.householdConfigurationRepository, member.householdId)
])
const purchaseResult = await options.purchaseRepository.save( const purchaseResult = await options.purchaseRepository.save(
purchaseRecord, purchaseRecord,
options.purchaseInterpreter, options.purchaseInterpreter,
settings.settlementCurrency settings.settlementCurrency,
{
householdContext: assistantConfig.assistantContext,
assistantTone: assistantConfig.assistantTone
}
) )
if (purchaseResult.status !== 'ignored_not_purchase') { if (purchaseResult.status !== 'ignored_not_purchase') {
@@ -1174,10 +1249,9 @@ export function registerDmAssistant(options: {
} }
const mention = stripExplicitBotMention(ctx) const mention = stripExplicitBotMention(ctx)
if (!mention || mention.strippedText.length === 0) { const isAddressed = Boolean(
await next() (mention && mention.strippedText.length > 0) || isReplyToBotMessage(ctx)
return )
}
const telegramUserId = ctx.from?.id?.toString() const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString() const telegramChatId = ctx.chat?.id?.toString()
@@ -1193,6 +1267,26 @@ export function registerDmAssistant(options: {
return return
} }
if (
!isAddressed &&
ctx.msg &&
'is_topic_message' in ctx.msg &&
ctx.msg.is_topic_message === true &&
'message_thread_id' in ctx.msg &&
ctx.msg.message_thread_id !== undefined
) {
const binding =
await options.householdConfigurationRepository.findHouseholdTopicByTelegramContext({
telegramChatId,
telegramThreadId: ctx.msg.message_thread_id.toString()
})
if (binding) {
await next()
return
}
}
const member = await options.householdConfigurationRepository.getHouseholdMember( const member = await options.householdConfigurationRepository.getHouseholdMember(
household.householdId, household.householdId,
telegramUserId telegramUserId
@@ -1203,13 +1297,6 @@ export function registerDmAssistant(options: {
} }
const locale = member.preferredLocale ?? household.defaultLocale ?? 'en' const locale = member.preferredLocale ?? household.defaultLocale ?? 'en'
const rateLimit = options.rateLimiter.consume(`${household.householdId}:${telegramUserId}`)
const t = getBotTranslations(locale).assistant
if (!rateLimit.allowed) {
await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs)))
return
}
const updateId = ctx.update.update_id?.toString() const updateId = ctx.update.update_id?.toString()
const dedupeClaim = const dedupeClaim =
@@ -1243,13 +1330,73 @@ export function registerDmAssistant(options: {
try { try {
const financeService = options.financeServiceForHousehold(household.householdId) const financeService = options.financeServiceForHousehold(household.householdId)
const [settings, assistantConfig] = await Promise.all([
options.householdConfigurationRepository.getHouseholdBillingSettings(household.householdId),
resolveAssistantConfig(options.householdConfigurationRepository, household.householdId)
])
const memoryKey = conversationMemoryKey({ const memoryKey = conversationMemoryKey({
telegramUserId, telegramUserId,
telegramChatId, telegramChatId,
isPrivateChat: false isPrivateChat: false
}) })
const messageText = mention?.strippedText ?? ctx.msg.text.trim()
if (options.purchaseRepository && options.purchaseInterpreter) {
const purchaseRecord = createGroupPurchaseRecord(ctx, household.householdId, messageText)
if (purchaseRecord) {
const purchaseResult = await options.purchaseRepository.save(
purchaseRecord,
options.purchaseInterpreter,
settings.settlementCurrency,
{
householdContext: assistantConfig.assistantContext,
assistantTone: assistantConfig.assistantTone
}
)
if (purchaseResult.status === 'pending_confirmation') {
const purchaseText = getBotTranslations(locale).purchase.proposal(
formatPurchaseSummary(locale, purchaseResult),
null
)
await ctx.reply(purchaseText, {
reply_markup: purchaseProposalReplyMarkup(locale, purchaseResult.purchaseMessageId)
})
return
}
if (purchaseResult.status === 'clarification_needed') {
await ctx.reply(buildPurchaseClarificationText(locale, purchaseResult))
return
}
if (!isAddressed) {
await next()
return
}
}
} else if (!isAddressed) {
await next()
return
}
if (!isAddressed || messageText.length === 0) {
await next()
return
}
const rateLimit = options.rateLimiter.consume(`${household.householdId}:${telegramUserId}`)
const t = getBotTranslations(locale).assistant
if (!rateLimit.allowed) {
await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs)))
return
}
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({ const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
rawText: mention.strippedText, rawText: messageText,
householdId: household.householdId, householdId: household.householdId,
memberId: member.id, memberId: member.id,
financeService, financeService,
@@ -1262,7 +1409,7 @@ export function registerDmAssistant(options: {
} }
const memberInsightReply = await maybeCreateMemberInsightReply({ const memberInsightReply = await maybeCreateMemberInsightReply({
rawText: mention.strippedText, rawText: messageText,
locale, locale,
householdId: household.householdId, householdId: household.householdId,
currentMemberId: member.id, currentMemberId: member.id,
@@ -1274,7 +1421,7 @@ export function registerDmAssistant(options: {
if (memberInsightReply) { if (memberInsightReply) {
options.memoryStore.appendTurn(memoryKey, { options.memoryStore.appendTurn(memoryKey, {
role: 'user', role: 'user',
text: mention.strippedText text: messageText
}) })
options.memoryStore.appendTurn(memoryKey, { options.memoryStore.appendTurn(memoryKey, {
role: 'assistant', role: 'assistant',
@@ -1294,7 +1441,7 @@ export function registerDmAssistant(options: {
telegramUserId, telegramUserId,
telegramChatId, telegramChatId,
locale, locale,
userMessage: mention.strippedText, userMessage: messageText,
householdConfigurationRepository: options.householdConfigurationRepository, householdConfigurationRepository: options.householdConfigurationRepository,
financeService, financeService,
memoryStore: options.memoryStore, memoryStore: options.memoryStore,

View File

@@ -185,6 +185,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
utilitiesReminderDay: input.utilitiesReminderDay ?? 3, utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi' timezone: input.timezone ?? 'Asia/Tbilisi'
}), }),
getHouseholdAssistantConfig: async (householdId) => ({
householdId,
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
}),
updateHouseholdAssistantConfig: async (input) => ({
householdId: input.householdId,
assistantContext: input.assistantContext ?? 'House in Kojori',
assistantTone: input.assistantTone ?? 'Playful'
}),
listHouseholdUtilityCategories: async () => [], listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({ upsertHouseholdUtilityCategory: async (input) => ({
id: input.slug ?? 'utility-category-1', id: input.slug ?? 'utility-category-1',
@@ -471,6 +481,11 @@ describe('createMiniAppSettingsHandler', () => {
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
paymentBalanceAdjustmentPolicy: 'utilities' paymentBalanceAdjustmentPolicy: 'utilities'
}, },
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
},
topics: [ topics: [
{ {
householdId: 'household-1', householdId: 'household-1',
@@ -566,6 +581,11 @@ describe('createMiniAppUpdateSettingsHandler', () => {
utilitiesReminderDay: 5, utilitiesReminderDay: 5,
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
paymentBalanceAdjustmentPolicy: 'utilities' paymentBalanceAdjustmentPolicy: 'utilities'
},
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
} }
}) })
}) })

View File

@@ -58,6 +58,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
utilitiesDueDay: number utilitiesDueDay: number
utilitiesReminderDay: number utilitiesReminderDay: number
timezone: string timezone: string
assistantContext?: string
assistantTone?: string
}> { }> {
const clonedRequest = request.clone() const clonedRequest = request.clone()
const payload = await readMiniAppRequestPayload(request) const payload = await readMiniAppRequestPayload(request)
@@ -76,6 +78,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
utilitiesDueDay?: number utilitiesDueDay?: number
utilitiesReminderDay?: number utilitiesReminderDay?: number
timezone?: string timezone?: string
assistantContext?: string
assistantTone?: string
} }
try { try {
parsed = JSON.parse(text) parsed = JSON.parse(text)
@@ -115,6 +119,16 @@ async function readSettingsUpdatePayload(request: Request): Promise<{
rentCurrency: parsed.rentCurrency rentCurrency: parsed.rentCurrency
} }
: {}), : {}),
...(typeof parsed.assistantContext === 'string'
? {
assistantContext: parsed.assistantContext
}
: {}),
...(typeof parsed.assistantTone === 'string'
? {
assistantTone: parsed.assistantTone
}
: {}),
rentDueDay: parsed.rentDueDay, rentDueDay: parsed.rentDueDay,
rentWarningDay: parsed.rentWarningDay, rentWarningDay: parsed.rentWarningDay,
utilitiesDueDay: parsed.utilitiesDueDay, utilitiesDueDay: parsed.utilitiesDueDay,
@@ -352,6 +366,18 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) {
} }
} }
function serializeAssistantConfig(config: {
householdId: string
assistantContext: string | null
assistantTone: string | null
}) {
return {
householdId: config.householdId,
assistantContext: config.assistantContext,
assistantTone: config.assistantTone
}
}
async function authenticateAdminSession( async function authenticateAdminSession(
request: Request, request: Request,
sessionService: ReturnType<typeof createMiniAppSessionService>, sessionService: ReturnType<typeof createMiniAppSessionService>,
@@ -520,6 +546,7 @@ export function createMiniAppSettingsHandler(options: {
ok: true, ok: true,
authorized: true, authorized: true,
settings: serializeBillingSettings(result.settings), settings: serializeBillingSettings(result.settings),
assistantConfig: serializeAssistantConfig(result.assistantConfig),
topics: result.topics, topics: result.topics,
categories: result.categories, categories: result.categories,
members: result.members, members: result.members,
@@ -617,7 +644,17 @@ export function createMiniAppUpdateSettingsHandler(options: {
rentWarningDay: payload.rentWarningDay, rentWarningDay: payload.rentWarningDay,
utilitiesDueDay: payload.utilitiesDueDay, utilitiesDueDay: payload.utilitiesDueDay,
utilitiesReminderDay: payload.utilitiesReminderDay, utilitiesReminderDay: payload.utilitiesReminderDay,
timezone: payload.timezone timezone: payload.timezone,
...(payload.assistantContext !== undefined
? {
assistantContext: payload.assistantContext
}
: {}),
...(payload.assistantTone !== undefined
? {
assistantTone: payload.assistantTone
}
: {})
}) })
if (result.status === 'rejected') { if (result.status === 'rejected') {
@@ -638,7 +675,8 @@ export function createMiniAppUpdateSettingsHandler(options: {
{ {
ok: true, ok: true,
authorized: true, authorized: true,
settings: serializeBillingSettings(result.settings) settings: serializeBillingSettings(result.settings),
assistantConfig: serializeAssistantConfig(result.assistantConfig)
}, },
200, 200,
origin origin

View File

@@ -21,6 +21,8 @@ export type PurchaseMessageInterpreter = (
options: { options: {
defaultCurrency: 'GEL' | 'USD' defaultCurrency: 'GEL' | 'USD'
clarificationContext?: PurchaseClarificationContext clarificationContext?: PurchaseClarificationContext
householdContext?: string | null
assistantTone?: string | null
} }
) => Promise<PurchaseInterpretation | null> ) => Promise<PurchaseInterpretation | null>
@@ -186,9 +188,18 @@ export function createOpenAiPurchaseInterpreter(
'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.',
'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 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.',
options.assistantTone
? `Use this tone lightly when asking clarification questions: ${options.assistantTone}.`
: null,
options.householdContext
? `Household flavor context: ${options.householdContext}`
: null,
'Return only JSON that matches the schema.' 'Return only JSON that matches the schema.'
].join(' ') ]
.filter(Boolean)
.join(' ')
}, },
{ {
role: 'user', role: 'user',

View File

@@ -157,7 +157,11 @@ export interface PurchaseMessageIngestionRepository {
save( save(
record: PurchaseTopicRecord, record: PurchaseTopicRecord,
interpreter?: PurchaseMessageInterpreter, interpreter?: PurchaseMessageInterpreter,
defaultCurrency?: 'GEL' | 'USD' defaultCurrency?: 'GEL' | 'USD',
options?: {
householdContext?: string | null
assistantTone?: string | null
}
): Promise<PurchaseMessageIngestionResult> ): Promise<PurchaseMessageIngestionResult>
confirm( confirm(
purchaseMessageId: string, purchaseMessageId: string,
@@ -820,7 +824,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
return Boolean(clarificationContext && clarificationContext.length > 0) return Boolean(clarificationContext && clarificationContext.length > 0)
}, },
async save(record, interpreter, defaultCurrency) { async save(record, interpreter, defaultCurrency, options) {
const matchedMember = await db const matchedMember = await db
.select({ id: schema.members.id }) .select({ id: schema.members.id })
.from(schema.members) .from(schema.members)
@@ -839,6 +843,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
const interpretation = interpreter const interpretation = interpreter
? await interpreter(record.rawText, { ? await interpreter(record.rawText, {
defaultCurrency: defaultCurrency ?? 'GEL', defaultCurrency: defaultCurrency ?? 'GEL',
householdContext: options?.householdContext ?? null,
assistantTone: options?.assistantTone ?? null,
...(clarificationContext ...(clarificationContext
? { ? {
clarificationContext: { clarificationContext: {
@@ -1190,6 +1196,23 @@ async function resolveHouseholdLocale(
return householdChat?.defaultLocale ?? 'en' return householdChat?.defaultLocale ?? 'en'
} }
async function resolveAssistantConfig(
householdConfigurationRepository: HouseholdConfigurationRepository,
householdId: string
): Promise<{
householdId: string
assistantContext: string | null
assistantTone: string | null
}> {
return householdConfigurationRepository.getHouseholdAssistantConfig
? await householdConfigurationRepository.getHouseholdAssistantConfig(householdId)
: {
householdId,
assistantContext: null,
assistantTone: null
}
}
async function handlePurchaseMessageResult( async function handlePurchaseMessageResult(
ctx: Context, ctx: Context,
record: PurchaseTopicRecord, record: PurchaseTopicRecord,
@@ -1529,9 +1552,10 @@ export function registerConfiguredPurchaseTopicIngestion(
const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null
try { try {
const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings( const [billingSettings, assistantConfig] = await Promise.all([
record.householdId householdConfigurationRepository.getHouseholdBillingSettings(record.householdId),
) resolveAssistantConfig(householdConfigurationRepository, record.householdId)
])
const locale = await resolveHouseholdLocale( const locale = await resolveHouseholdLocale(
householdConfigurationRepository, householdConfigurationRepository,
record.householdId record.householdId
@@ -1542,7 +1566,11 @@ export function registerConfiguredPurchaseTopicIngestion(
const result = await repository.save( const result = await repository.save(
record, record,
options.interpreter, options.interpreter,
billingSettings.settlementCurrency billingSettings.settlementCurrency,
{
householdContext: assistantConfig.assistantContext,
assistantTone: assistantConfig.assistantTone
}
) )
if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') { if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') {
return await next() return await next()

View File

@@ -37,7 +37,14 @@ describe('createBotWebhookServer', () => {
miniAppSettings: { miniAppSettings: {
handler: async () => handler: async () =>
new Response( new Response(
JSON.stringify({ ok: true, authorized: true, settings: {}, categories: [], members: [] }), JSON.stringify({
ok: true,
authorized: true,
settings: {},
assistantConfig: {},
categories: [],
members: []
}),
{ {
status: 200, status: 200,
headers: { headers: {
@@ -48,12 +55,15 @@ describe('createBotWebhookServer', () => {
}, },
miniAppUpdateSettings: { miniAppUpdateSettings: {
handler: async () => handler: async () =>
new Response(JSON.stringify({ ok: true, authorized: true, settings: {} }), { new Response(
JSON.stringify({ ok: true, authorized: true, settings: {}, assistantConfig: {} }),
{
status: 200, status: 200,
headers: { headers: {
'content-type': 'application/json; charset=utf-8' 'content-type': 'application/json; charset=utf-8'
} }
}) }
)
}, },
miniAppUpsertUtilityCategory: { miniAppUpsertUtilityCategory: {
handler: async () => handler: async () =>
@@ -305,6 +315,7 @@ describe('createBotWebhookServer', () => {
ok: true, ok: true,
authorized: true, authorized: true,
settings: {}, settings: {},
assistantConfig: {},
categories: [], categories: [],
members: [] members: []
}) })
@@ -322,7 +333,8 @@ describe('createBotWebhookServer', () => {
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
ok: true, ok: true,
authorized: true, authorized: true,
settings: {} settings: {},
assistantConfig: {}
}) })
}) })

View File

@@ -376,7 +376,9 @@ function App() {
rentWarningDay: 17, rentWarningDay: 17,
utilitiesDueDay: 4, utilitiesDueDay: 4,
utilitiesReminderDay: 3, utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi' timezone: 'Asia/Tbilisi',
assistantContext: '',
assistantTone: ''
}) })
const [newCategoryName, setNewCategoryName] = createSignal('') const [newCategoryName, setNewCategoryName] = createSignal('')
const [cycleForm, setCycleForm] = createSignal({ const [cycleForm, setCycleForm] = createSignal({
@@ -917,7 +919,9 @@ function App() {
rentWarningDay: payload.settings.rentWarningDay, rentWarningDay: payload.settings.rentWarningDay,
utilitiesDueDay: payload.settings.utilitiesDueDay, utilitiesDueDay: payload.settings.utilitiesDueDay,
utilitiesReminderDay: payload.settings.utilitiesReminderDay, utilitiesReminderDay: payload.settings.utilitiesReminderDay,
timezone: payload.settings.timezone timezone: payload.settings.timezone,
assistantContext: payload.assistantConfig.assistantContext ?? '',
assistantTone: payload.assistantConfig.assistantTone ?? ''
}) })
setPaymentForm((current) => ({ setPaymentForm((current) => ({
...current, ...current,
@@ -1033,7 +1037,9 @@ function App() {
rentWarningDay: demoAdminSettings.settings.rentWarningDay, rentWarningDay: demoAdminSettings.settings.rentWarningDay,
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay, utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay, utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
timezone: demoAdminSettings.settings.timezone timezone: demoAdminSettings.settings.timezone,
assistantContext: demoAdminSettings.assistantConfig.assistantContext ?? '',
assistantTone: demoAdminSettings.assistantConfig.assistantTone ?? ''
}) })
setCycleForm((current) => ({ setCycleForm((current) => ({
...current, ...current,
@@ -1338,12 +1344,16 @@ function App() {
setSavingBillingSettings(true) setSavingBillingSettings(true)
try { try {
const settings = await updateMiniAppBillingSettings(initData, billingForm()) const { settings, assistantConfig } = await updateMiniAppBillingSettings(
initData,
billingForm()
)
setAdminSettings((current) => setAdminSettings((current) =>
current current
? { ? {
...current, ...current,
settings settings,
assistantConfig
} }
: current : current
) )
@@ -2230,6 +2240,18 @@ function App() {
timezone: value timezone: value
})) }))
} }
onBillingAssistantContextChange={(value) =>
setBillingForm((current) => ({
...current,
assistantContext: value
}))
}
onBillingAssistantToneChange={(value) =>
setBillingForm((current) => ({
...current,
assistantTone: value
}))
}
onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)} onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)}
onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)} onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)}
onAddUtilityBill={handleAddUtilityBill} onAddUtilityBill={handleAddUtilityBill}

View File

@@ -203,6 +203,11 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
utilitiesReminderDay: 3, utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi' timezone: 'Asia/Tbilisi'
}, },
assistantConfig: {
householdId: 'demo-household',
assistantContext: 'The household is a house in Kojori with a backyard and pine forest nearby.',
assistantTone: 'Playful but concise'
},
topics: [ topics: [
{ role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' }, { role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' },
{ role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' }, { role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' },

View File

@@ -147,6 +147,16 @@ export const dictionary = {
topicBound: 'Bound', topicBound: 'Bound',
topicUnbound: 'Unbound', topicUnbound: 'Unbound',
billingSettingsTitle: 'Billing settings', billingSettingsTitle: 'Billing settings',
assistantSettingsTitle: 'Bot personality',
assistantSettingsBody:
'Give the bot household context and a tone so replies feel grounded without getting intrusive.',
assistantToneLabel: 'Bot mood',
assistantTonePlaceholder: 'Playful, dry, concise, slightly sarcastic',
assistantToneDefault: 'Default',
assistantContextLabel: 'Household context',
assistantContextPlaceholder:
'The household is a house in Kojori with a backyard and pine forest nearby.',
assistantContextEmpty: 'No custom context',
settlementCurrency: 'Settlement currency', settlementCurrency: 'Settlement currency',
paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment', paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment',
paymentBalanceAdjustmentUtilities: 'Adjust through utilities', paymentBalanceAdjustmentUtilities: 'Adjust through utilities',
@@ -392,6 +402,15 @@ export const dictionary = {
topicBound: 'Привязан', topicBound: 'Привязан',
topicUnbound: 'Не привязан', topicUnbound: 'Не привязан',
billingSettingsTitle: 'Настройки биллинга', billingSettingsTitle: 'Настройки биллинга',
assistantSettingsTitle: 'Характер бота',
assistantSettingsBody:
'Задай бытовой контекст и тон, чтобы ответы бота звучали уместно и не лезли в разговор без повода.',
assistantToneLabel: 'Настроение бота',
assistantTonePlaceholder: 'Игривый, сухой, короткий, слегка саркастичный',
assistantToneDefault: 'По умолчанию',
assistantContextLabel: 'Контекст дома',
assistantContextPlaceholder: 'Это дом в Коджори, рядом двор и сосновый лес.',
assistantContextEmpty: 'Контекст не задан',
settlementCurrency: 'Валюта расчёта', settlementCurrency: 'Валюта расчёта',
paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам', paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам',
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку', paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',

View File

@@ -70,6 +70,12 @@ export interface MiniAppBillingSettings {
timezone: string timezone: string
} }
export interface MiniAppAssistantConfig {
householdId: string
assistantContext: string | null
assistantTone: string | null
}
export interface MiniAppUtilityCategory { export interface MiniAppUtilityCategory {
id: string id: string
householdId: string householdId: string
@@ -133,6 +139,7 @@ export interface MiniAppDashboard {
export interface MiniAppAdminSettingsPayload { export interface MiniAppAdminSettingsPayload {
settings: MiniAppBillingSettings settings: MiniAppBillingSettings
assistantConfig: MiniAppAssistantConfig
topics: readonly MiniAppTopicBinding[] topics: readonly MiniAppTopicBinding[]
categories: readonly MiniAppUtilityCategory[] categories: readonly MiniAppUtilityCategory[]
members: readonly MiniAppMember[] members: readonly MiniAppMember[]
@@ -380,6 +387,7 @@ export async function fetchMiniAppAdminSettings(
ok: boolean ok: boolean
authorized?: boolean authorized?: boolean
settings?: MiniAppBillingSettings settings?: MiniAppBillingSettings
assistantConfig?: MiniAppAssistantConfig
topics?: MiniAppTopicBinding[] topics?: MiniAppTopicBinding[]
categories?: MiniAppUtilityCategory[] categories?: MiniAppUtilityCategory[]
members?: MiniAppMember[] members?: MiniAppMember[]
@@ -391,6 +399,7 @@ export async function fetchMiniAppAdminSettings(
!response.ok || !response.ok ||
!payload.authorized || !payload.authorized ||
!payload.settings || !payload.settings ||
!payload.assistantConfig ||
!payload.topics || !payload.topics ||
!payload.categories || !payload.categories ||
!payload.members || !payload.members ||
@@ -401,6 +410,7 @@ export async function fetchMiniAppAdminSettings(
return { return {
settings: payload.settings, settings: payload.settings,
assistantConfig: payload.assistantConfig,
topics: payload.topics, topics: payload.topics,
categories: payload.categories, categories: payload.categories,
members: payload.members, members: payload.members,
@@ -420,8 +430,13 @@ export async function updateMiniAppBillingSettings(
utilitiesDueDay: number utilitiesDueDay: number
utilitiesReminderDay: number utilitiesReminderDay: number
timezone: string timezone: string
assistantContext?: string
assistantTone?: string
} }
): Promise<MiniAppBillingSettings> { ): Promise<{
settings: MiniAppBillingSettings
assistantConfig: MiniAppAssistantConfig
}> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, { const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -437,14 +452,18 @@ export async function updateMiniAppBillingSettings(
ok: boolean ok: boolean
authorized?: boolean authorized?: boolean
settings?: MiniAppBillingSettings settings?: MiniAppBillingSettings
assistantConfig?: MiniAppAssistantConfig
error?: string error?: string
} }
if (!response.ok || !payload.authorized || !payload.settings) { if (!response.ok || !payload.authorized || !payload.settings || !payload.assistantConfig) {
throw new Error(payload.error ?? 'Failed to update billing settings') throw new Error(payload.error ?? 'Failed to update billing settings')
} }
return payload.settings return {
settings: payload.settings,
assistantConfig: payload.assistantConfig
}
} }
export async function upsertMiniAppUtilityCategory( export async function upsertMiniAppUtilityCategory(

View File

@@ -37,6 +37,8 @@ type BillingForm = {
utilitiesDueDay: number utilitiesDueDay: number
utilitiesReminderDay: number utilitiesReminderDay: number
timezone: string timezone: string
assistantContext: string
assistantTone: string
} }
type CycleForm = { type CycleForm = {
@@ -121,6 +123,8 @@ type Props = {
onBillingUtilitiesDueDayChange: (value: number | null) => void onBillingUtilitiesDueDayChange: (value: number | null) => void
onBillingUtilitiesReminderDayChange: (value: number | null) => void onBillingUtilitiesReminderDayChange: (value: number | null) => void
onBillingTimezoneChange: (value: string) => void onBillingTimezoneChange: (value: string) => void
onBillingAssistantContextChange: (value: string) => void
onBillingAssistantToneChange: (value: string) => void
onOpenAddUtilityBill: () => void onOpenAddUtilityBill: () => void
onCloseAddUtilityBill: () => void onCloseAddUtilityBill: () => void
onAddUtilityBill: () => Promise<void> onAddUtilityBill: () => Promise<void>
@@ -260,23 +264,22 @@ export function HouseScreen(props: Props) {
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{props.copy.billingSettingsTitle ?? ''}</strong> <strong>{props.copy.assistantSettingsTitle ?? ''}</strong>
<span>{props.billingForm.settlementCurrency}</span> <span>
{props.billingForm.assistantTone || (props.copy.assistantToneDefault ?? '')}
</span>
</header> </header>
<p> <p>{props.copy.assistantSettingsBody ?? ''}</p>
{props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities'
? props.copy.paymentBalanceAdjustmentUtilities
: props.billingForm.paymentBalanceAdjustmentPolicy === 'rent'
? props.copy.paymentBalanceAdjustmentRent
: props.copy.paymentBalanceAdjustmentSeparate}
</p>
<div class="ledger-compact-card__meta"> <div class="ledger-compact-card__meta">
<span class="mini-chip"> <span class="mini-chip">
{props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '} {props.copy.assistantToneLabel ?? ''}:{' '}
{props.billingForm.rentCurrency} {props.billingForm.assistantTone || props.copy.assistantToneDefault || '—'}
</span> </span>
<span class="mini-chip mini-chip--muted"> <span class="mini-chip mini-chip--muted">
{props.copy.timezone ?? ''}: {props.billingForm.timezone} {props.copy.assistantContextLabel ?? ''}:{' '}
{props.billingForm.assistantContext.trim().length > 0
? props.billingForm.assistantContext.trim().slice(0, 80)
: (props.copy.assistantContextEmpty ?? '')}
</span> </span>
</div> </div>
<div class="panel-toolbar"> <div class="panel-toolbar">
@@ -514,6 +517,27 @@ export function HouseScreen(props: Props) {
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)} onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
/> />
</Field> </Field>
<Field label={props.copy.assistantToneLabel ?? ''} wide>
<input
value={props.billingForm.assistantTone}
maxlength="160"
placeholder={props.copy.assistantTonePlaceholder ?? ''}
onInput={(event) =>
props.onBillingAssistantToneChange(event.currentTarget.value)
}
/>
</Field>
<Field label={props.copy.assistantContextLabel ?? ''} wide>
<textarea
rows="6"
maxlength="1200"
placeholder={props.copy.assistantContextPlaceholder ?? ''}
value={props.billingForm.assistantContext}
onInput={(event) =>
props.onBillingAssistantContextChange(event.currentTarget.value)
}
/>
</Field>
</div> </div>
</Modal> </Modal>
</section> </section>

View File

@@ -12,6 +12,7 @@ import {
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES, HOUSEHOLD_PAYMENT_BALANCE_ADJUSTMENT_POLICIES,
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdAssistantConfigRecord,
type HouseholdMemberAbsencePolicy, type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord, type HouseholdMemberAbsencePolicyRecord,
type HouseholdBillingSettingsRecord, type HouseholdBillingSettingsRecord,
@@ -245,6 +246,18 @@ function toHouseholdBillingSettingsRecord(row: {
} }
} }
function toHouseholdAssistantConfigRecord(row: {
householdId: string
assistantContext: string | null
assistantTone: string | null
}): HouseholdAssistantConfigRecord {
return {
householdId: row.householdId,
assistantContext: row.assistantContext,
assistantTone: row.assistantTone
}
}
function toHouseholdUtilityCategoryRecord(row: { function toHouseholdUtilityCategoryRecord(row: {
id: string id: string
householdId: string householdId: string
@@ -957,6 +970,25 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return toHouseholdBillingSettingsRecord(row) return toHouseholdBillingSettingsRecord(row)
}, },
async getHouseholdAssistantConfig(householdId) {
const rows = await db
.select({
householdId: schema.households.id,
assistantContext: schema.households.assistantContext,
assistantTone: schema.households.assistantTone
})
.from(schema.households)
.where(eq(schema.households.id, householdId))
.limit(1)
const row = rows[0]
if (!row) {
throw new Error('Failed to load household assistant config')
}
return toHouseholdAssistantConfigRecord(row)
},
async updateHouseholdBillingSettings(input) { async updateHouseholdBillingSettings(input) {
await ensureBillingSettings(input.householdId) await ensureBillingSettings(input.householdId)
@@ -1033,6 +1065,36 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return toHouseholdBillingSettingsRecord(row) return toHouseholdBillingSettingsRecord(row)
}, },
async updateHouseholdAssistantConfig(input) {
const rows = await db
.update(schema.households)
.set({
...(input.assistantContext !== undefined
? {
assistantContext: input.assistantContext
}
: {}),
...(input.assistantTone !== undefined
? {
assistantTone: input.assistantTone
}
: {})
})
.where(eq(schema.households.id, input.householdId))
.returning({
householdId: schema.households.id,
assistantContext: schema.households.assistantContext,
assistantTone: schema.households.assistantTone
})
const row = rows[0]
if (!row) {
throw new Error('Failed to update household assistant config')
}
return toHouseholdAssistantConfigRecord(row)
},
async listHouseholdUtilityCategories(householdId) { async listHouseholdUtilityCategories(householdId) {
await ensureUtilityCategories(householdId) await ensureUtilityCategories(householdId)

View File

@@ -172,6 +172,16 @@ function repository(): HouseholdConfigurationRepository {
utilitiesReminderDay: input.utilitiesReminderDay ?? 3, utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi' timezone: input.timezone ?? 'Asia/Tbilisi'
}), }),
getHouseholdAssistantConfig: async (householdId) => ({
householdId,
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
}),
updateHouseholdAssistantConfig: async (input) => ({
householdId: input.householdId,
assistantContext: input.assistantContext ?? 'House in Kojori',
assistantTone: input.assistantTone ?? 'Playful'
}),
listHouseholdUtilityCategories: async () => [], listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({ upsertHouseholdUtilityCategory: async (input) => ({
id: input.slug ?? 'utility-category-1', id: input.slug ?? 'utility-category-1',
@@ -269,6 +279,11 @@ describe('createMiniAppAdminService', () => {
utilitiesReminderDay: 3, utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi' timezone: 'Asia/Tbilisi'
}, },
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
},
topics: [ topics: [
{ {
householdId: 'household-1', householdId: 'household-1',
@@ -322,6 +337,11 @@ describe('createMiniAppAdminService', () => {
utilitiesDueDay: 5, utilitiesDueDay: 5,
utilitiesReminderDay: 4, utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi' timezone: 'Asia/Tbilisi'
},
assistantConfig: {
householdId: 'household-1',
assistantContext: 'House in Kojori',
assistantTone: 'Playful'
} }
}) })
}) })

View File

@@ -1,4 +1,5 @@
import type { import type {
HouseholdAssistantConfigRecord,
HouseholdBillingSettingsRecord, HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy, HouseholdMemberAbsencePolicy,
@@ -29,6 +30,7 @@ export interface MiniAppAdminService {
| { | {
status: 'ok' status: 'ok'
settings: HouseholdBillingSettingsRecord settings: HouseholdBillingSettingsRecord
assistantConfig: HouseholdAssistantConfigRecord
categories: readonly HouseholdUtilityCategoryRecord[] categories: readonly HouseholdUtilityCategoryRecord[]
members: readonly HouseholdMemberRecord[] members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[] memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
@@ -51,10 +53,13 @@ export interface MiniAppAdminService {
utilitiesDueDay: number utilitiesDueDay: number
utilitiesReminderDay: number utilitiesReminderDay: number
timezone: string timezone: string
assistantContext?: string
assistantTone?: string
}): Promise< }): Promise<
| { | {
status: 'ok' status: 'ok'
settings: HouseholdBillingSettingsRecord settings: HouseholdBillingSettingsRecord
assistantConfig: HouseholdAssistantConfigRecord
} }
| { | {
status: 'rejected' status: 'rejected'
@@ -210,6 +215,34 @@ function normalizeDisplayName(raw: string): string | null {
return trimmed.replace(/\s+/g, ' ') return trimmed.replace(/\s+/g, ' ')
} }
function defaultAssistantConfig(householdId: string): HouseholdAssistantConfigRecord {
return {
householdId,
assistantContext: null,
assistantTone: null
}
}
function normalizeAssistantText(
raw: string | undefined,
maxLength: number
): string | null | undefined {
if (raw === undefined) {
return undefined
}
const trimmed = raw.trim()
if (trimmed.length === 0) {
return null
}
if (trimmed.length > maxLength) {
return null
}
return trimmed
}
export function createMiniAppAdminService( export function createMiniAppAdminService(
repository: HouseholdConfigurationRepository repository: HouseholdConfigurationRepository
): MiniAppAdminService { ): MiniAppAdminService {
@@ -222,8 +255,12 @@ export function createMiniAppAdminService(
} }
} }
const [settings, categories, members, memberAbsencePolicies, topics] = await Promise.all([ const [settings, assistantConfig, categories, members, memberAbsencePolicies, topics] =
await Promise.all([
repository.getHouseholdBillingSettings(input.householdId), repository.getHouseholdBillingSettings(input.householdId),
repository.getHouseholdAssistantConfig
? repository.getHouseholdAssistantConfig(input.householdId)
: Promise.resolve(defaultAssistantConfig(input.householdId)),
repository.listHouseholdUtilityCategories(input.householdId), repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId), repository.listHouseholdMembers(input.householdId),
repository.listHouseholdMemberAbsencePolicies(input.householdId), repository.listHouseholdMemberAbsencePolicies(input.householdId),
@@ -233,6 +270,7 @@ export function createMiniAppAdminService(
return { return {
status: 'ok', status: 'ok',
settings, settings,
assistantConfig,
categories, categories,
members, members,
memberAbsencePolicies, memberAbsencePolicies,
@@ -263,6 +301,23 @@ export function createMiniAppAdminService(
} }
} }
const assistantContext = normalizeAssistantText(input.assistantContext, 1200)
const assistantTone = normalizeAssistantText(input.assistantTone, 160)
if (
(input.assistantContext !== undefined &&
assistantContext === null &&
input.assistantContext.trim().length > 0) ||
(input.assistantTone !== undefined &&
assistantTone === null &&
input.assistantTone.trim().length > 0)
) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
let rentAmountMinor: bigint | null | undefined let rentAmountMinor: bigint | null | undefined
let rentCurrency: CurrencyCode | undefined let rentCurrency: CurrencyCode | undefined
const settlementCurrency = input.settlementCurrency const settlementCurrency = input.settlementCurrency
@@ -291,7 +346,11 @@ export function createMiniAppAdminService(
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD') rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
} }
const settings = await repository.updateHouseholdBillingSettings({ const shouldUpdateAssistantConfig =
assistantContext !== undefined || assistantTone !== undefined
const [settings, nextAssistantConfig] = await Promise.all([
repository.updateHouseholdBillingSettings({
householdId: input.householdId, householdId: input.householdId,
...(settlementCurrency ...(settlementCurrency
? { ? {
@@ -318,11 +377,34 @@ export function createMiniAppAdminService(
utilitiesDueDay: input.utilitiesDueDay, utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay, utilitiesReminderDay: input.utilitiesReminderDay,
timezone: input.timezone.trim() timezone: input.timezone.trim()
}),
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
? repository.updateHouseholdAssistantConfig({
householdId: input.householdId,
...(assistantContext !== undefined
? {
assistantContext
}
: {}),
...(assistantTone !== undefined
? {
assistantTone
}
: {})
}) })
: repository.getHouseholdAssistantConfig
? repository.getHouseholdAssistantConfig(input.householdId)
: Promise.resolve({
householdId: input.householdId,
assistantContext: assistantContext ?? null,
assistantTone: assistantTone ?? null
})
])
return { return {
status: 'ok', status: 'ok',
settings settings,
assistantConfig: nextAssistantConfig
} }
}, },

View File

@@ -18,6 +18,7 @@
"0014_empty_risque.sql": "6dd4aba0f84d43bc86afbd04cad3b1055ecac03bd80ad6fd510bef1550d10335", "0014_empty_risque.sql": "6dd4aba0f84d43bc86afbd04cad3b1055ecac03bd80ad6fd510bef1550d10335",
"0015_white_owl.sql": "a9dec4c536c660d7eb0fcea42a3bedb1301408551977d098dff8324d7d5b26bd", "0015_white_owl.sql": "a9dec4c536c660d7eb0fcea42a3bedb1301408551977d098dff8324d7d5b26bd",
"0016_equal_susan_delgado.sql": "1698bf0516d16d2d7929dcb1bd2bb76d5a629eaba3d0bb2533c1ae926408de7a", "0016_equal_susan_delgado.sql": "1698bf0516d16d2d7929dcb1bd2bb76d5a629eaba3d0bb2533c1ae926408de7a",
"0017_gigantic_selene.sql": "232d61b979675ddb97c9d69d14406dc15dd095ee6a332d3fa71d10416204fade" "0017_gigantic_selene.sql": "232d61b979675ddb97c9d69d14406dc15dd095ee6a332d3fa71d10416204fade",
"0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc"
} }
} }

View File

@@ -0,0 +1,2 @@
ALTER TABLE "households" ADD COLUMN "assistant_context" text;
ALTER TABLE "households" ADD COLUMN "assistant_tone" text;

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,13 @@
"when": 1773226133315, "when": 1773226133315,
"tag": "0017_gigantic_selene", "tag": "0017_gigantic_selene",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773252000000,
"tag": "0018_nimble_kojori",
"breakpoints": true
} }
] ]
} }

View File

@@ -16,6 +16,8 @@ export const households = pgTable('households', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(), name: text('name').notNull(),
defaultLocale: text('default_locale').default('ru').notNull(), defaultLocale: text('default_locale').default('ru').notNull(),
assistantContext: text('assistant_context'),
assistantTone: text('assistant_tone'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
}) })

View File

@@ -86,6 +86,12 @@ export interface HouseholdBillingSettingsRecord {
timezone: string timezone: string
} }
export interface HouseholdAssistantConfigRecord {
householdId: string
assistantContext: string | null
assistantTone: string | null
}
export interface HouseholdUtilityCategoryRecord { export interface HouseholdUtilityCategoryRecord {
id: string id: string
householdId: string householdId: string
@@ -166,6 +172,7 @@ export interface HouseholdConfigurationRepository {
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]> listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]>
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord> getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
getHouseholdAssistantConfig?(householdId: string): Promise<HouseholdAssistantConfigRecord>
updateHouseholdBillingSettings(input: { updateHouseholdBillingSettings(input: {
householdId: string householdId: string
settlementCurrency?: CurrencyCode settlementCurrency?: CurrencyCode
@@ -178,6 +185,11 @@ export interface HouseholdConfigurationRepository {
utilitiesReminderDay?: number utilitiesReminderDay?: number
timezone?: string timezone?: string
}): Promise<HouseholdBillingSettingsRecord> }): Promise<HouseholdBillingSettingsRecord>
updateHouseholdAssistantConfig?(input: {
householdId: string
assistantContext?: string | null
assistantTone?: string | null
}): Promise<HouseholdAssistantConfigRecord>
listHouseholdUtilityCategories( listHouseholdUtilityCategories(
householdId: string householdId: string
): Promise<readonly HouseholdUtilityCategoryRecord[]> ): Promise<readonly HouseholdUtilityCategoryRecord[]>

View File

@@ -19,6 +19,7 @@ export {
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdMemberAbsencePolicy, type HouseholdMemberAbsencePolicy,
type HouseholdMemberAbsencePolicyRecord, type HouseholdMemberAbsencePolicyRecord,
type HouseholdAssistantConfigRecord,
type HouseholdPaymentBalanceAdjustmentPolicy, type HouseholdPaymentBalanceAdjustmentPolicy,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord, type HouseholdBillingSettingsRecord,