Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ GEMINI_VISION_MODEL=gemini-2.5-flash
# AI debug logs — full conversation logs to logs/chats/{chatId}/
# AI_DEBUG_LOGS=true

# Set true to send proactive AI financial insights to chat.
# false (default): triggers are logged for analysis but not sent.
# Budget-exceeded alerts fire regardless of this setting.
AUTO_ADVICE_ENABLED=false

# GitHub (fine-grained PAT for dev pipeline PR operations)
GITHUB_TOKEN=github_pat_XXX

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,8 @@ Use these MCP servers proactively whenever they can help:
- Specs: `docs/specs/YYYY-MM-DD-<topic>.md` — design documents and feature specifications
- Plans: `docs/plans/YYYY-MM-DD-<topic>.md` — implementation plans with task breakdowns

**Plans and specs MUST be committed and pushed** — they are part of the codebase, not ephemeral scratch files. After saving a plan or spec, `git add docs/` and commit it on the feature branch. Never leave them untracked.

**OVERRIDE:** Skills that default to `docs/superpowers/plans/` or similar paths MUST use `docs/plans/` and `docs/specs/` instead. No `superpowers/` subdirectory.

## Logging
Expand Down
487 changes: 487 additions & 0 deletions docs/plans/2026-05-21-budget-exceeded-alerts-only.md

Large diffs are not rendered by default.

103 changes: 59 additions & 44 deletions src/bot/commands/ask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,45 +427,84 @@ const { maybeSmartAdvice } = await import('./ask');

describe('maybeSmartAdvice', () => {
beforeEach(() => {
mockAiStreamRound.mockClear();
mockSendMessage.mockClear();
mockAdviceLogs.create.mockClear();
mockAiStreamRound.mockReset();
recordAdviceSentMock.mockClear();
checkSmartTriggersMock.mockReset().mockReturnValue(null);
logMock.error.mockReset();
logMock.info.mockReset();
logMock.warn.mockReset();
writerCalls.appended.length = 0;
writerCalls.finalized.length = 0;
writerCalls.finalizedErrors.length = 0;
writerCalls.closed = 0;
logMock.error.mockReset();
logMock.warn.mockReset();
logMock.info.mockReset();
});

const velocityTrigger = {
type: 'velocity_spike' as const,
tier: 'quick' as const,
topic: 'velocity_spike',
data: { acceleration: 80, recent_avg: 60, earlier_avg: 30 },
};

test('does nothing when checkSmartTriggers returns null', async () => {
const spy = spyOnChecker(null);
await maybeSmartAdvice(1);

expect(mockAiStreamRound).not.toHaveBeenCalled();
expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockAdviceLogs.create).not.toHaveBeenCalled();
spy.mockRestore();
expect(mockAiStreamRound).not.toHaveBeenCalled();
expect(logMock.error).not.toHaveBeenCalled();
});

test('fires aiStreamRound when trigger is returned', async () => {
successfulStream(['quick insight']);
const spy = spyOnChecker({
type: 'budget_threshold',
tier: 'quick',
topic: 'budget_threshold:Food:warning',
data: { category: 'Food' },
});
test('other trigger + AUTO_ADVICE_ENABLED=true: calls AI via sendSmartAdvice', async () => {
const envModule = await import('../../config/env');
(envModule.env as unknown as Record<string, unknown>)['AUTO_ADVICE_ENABLED'] = true;

checkSmartTriggersMock.mockReturnValueOnce(velocityTrigger);
mockAiStreamRound.mockImplementationOnce(
async (_opts: unknown, callbacks?: { onTextDelta?: (t: string) => void }) => {
callbacks?.onTextDelta?.('траты растут');
return {
text: 'траты растут',
toolCalls: [],
finishReason: 'stop',
assistantMessage: { role: 'assistant', content: 'траты растут' },
providerUsed: 'mock',
};
},
);

await maybeSmartAdvice(1);

expect(mockAiStreamRound).toHaveBeenCalledTimes(1);
const [opts] = mockAiStreamRound.mock.calls[0] as unknown as [StreamRoundOptions];
// Quick tier uses 500 max tokens
expect(opts.maxTokens).toBe(500);
spy.mockRestore();
expect(logMock.error).not.toHaveBeenCalled();

(envModule.env as unknown as Record<string, unknown>)['AUTO_ADVICE_ENABLED'] = false;
});

test('swallows errors (does not propagate)', async () => {
test('other trigger + AUTO_ADVICE_ENABLED=false: logs suppressed, no message, persists cooldown', async () => {
checkSmartTriggersMock.mockReturnValueOnce(velocityTrigger);

await maybeSmartAdvice(1);

expect(mockSendMessage).not.toHaveBeenCalled();
expect(mockAiStreamRound).not.toHaveBeenCalled();
// Cooldown recorded so same tier doesn't re-fire within 4h
expect(recordAdviceSentMock).toHaveBeenCalledWith(1, 'quick');
// advice_log written so monthly dedup works
expect(mockAdviceLogs.create).toHaveBeenCalledTimes(1);
const arg = mockAdviceLogs.create.mock.calls[0]?.[0] as Record<string, unknown>;
expect(arg['advice_text']).toBe('[auto-advice suppressed]');
// Full context logged for offline analysis
const suppressedLog = logMock.info.mock.calls.find((c) =>
JSON.stringify(c).includes('Auto-advice suppressed'),
);
expect(suppressedLog).toBeDefined();
expect(logMock.error).not.toHaveBeenCalled();
});

test('swallows errors without propagating', async () => {
const spa = await import('../../services/analytics/spending-analytics');
(spa.spendingAnalytics.getFinancialSnapshot as ReturnType<typeof mock>).mockImplementationOnce(
() => {
Expand All @@ -476,28 +515,4 @@ describe('maybeSmartAdvice', () => {
await expect(maybeSmartAdvice(1)).resolves.toBeUndefined();
expect(logMock.error).toHaveBeenCalled();
});

test('uses alert tier config (1000 max_tokens) when trigger.tier=alert', async () => {
successfulStream(['alert text']);
const spy = spyOnChecker({
type: 'budget_threshold',
tier: 'alert',
topic: 'budget_threshold:Food:exceeded',
data: { category: 'Food' },
});

await maybeSmartAdvice(1);

const [opts] = mockAiStreamRound.mock.calls[0] as unknown as [StreamRoundOptions];
expect(opts.maxTokens).toBe(1000);
spy.mockRestore();
});
});

// helper: sets the mocked checkSmartTriggers return value for the next call
function spyOnChecker(returnValue: import('../../services/analytics/types').TriggerResult | null): {
mockRestore: () => void;
} {
checkSmartTriggersMock.mockImplementationOnce(() => returnValue);
return { mockRestore: () => {} };
}
52 changes: 46 additions & 6 deletions src/bot/commands/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,19 +212,61 @@ export async function handleAdviceCommand(ctx: Ctx['Command'], group: Group): Pr
}

/**
* Check smart triggers and maybe send advice
* Check smart triggers and dispatch:
* budget_threshold:exceeded → always send factual message to chat + write advice_log
* other trigger, AUTO_ADVICE_ENABLED=true → send AI advice via sendSmartAdvice
* other trigger, AUTO_ADVICE_ENABLED=false → log context for analysis only
*
* Once-per-month dedup for budget_exceeded: checkSmartTriggers calls hasTopicThisMonth
* before returning the trigger, so if we wrote an advice_log entry this month it returns
* null before we even get here.
*/
export async function maybeSmartAdvice(groupId: number): Promise<void> {
try {
const snapshot = spendingAnalytics.getFinancialSnapshot(groupId);
const trigger = checkSmartTriggers(groupId, snapshot);

if (!trigger) return;

// Other triggers: send to chat when flag is on.
if (env.AUTO_ADVICE_ENABLED) {
await sendSmartAdvice(groupId, trigger, snapshot);
recordAdviceSent(groupId, trigger.tier);
Comment on lines +232 to +233

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record cooldown only after advice is actually delivered

recordAdviceSent now runs unconditionally after sendSmartAdvice, but sendSmartAdvice can return without sending anything (e.g., provider failure is swallowed in its catch, validator rejection, or short/empty output). In those cases this still consumes the in-memory tier cooldown, so subsequent triggers are suppressed for 1h/4h even though the user received no advice.

Useful? React with 👍 / 👎.

return;
}

// Flag is off: log trigger context for offline analysis.
// recordAdviceSent sets in-memory cooldown so the same tier doesn't re-fire
// on every expense within the cooldown window (4h quick / 1h alert).
// advice_log entry activates hasTopicThisMonth dedup for monthly triggers.
const group = database.groups.findById(groupId);
const snapshotText = formatSnapshotForPrompt(
snapshot,
groupId,
group?.default_currency ?? BASE_CURRENCY,
);
logger.info(
`[ADVICE] Smart trigger fired: ${trigger.type} (tier: ${trigger.tier}) for group ${groupId}`,
{
groupId,
trigger: {
type: trigger.type,
tier: trigger.tier,
topic: trigger.topic,
data: trigger.data,
},
severity: computeOverallSeverity(snapshot),
context: snapshotText,
},
'[ADVICE] Auto-advice suppressed — trigger would have fired',
);
await sendSmartAdvice(groupId, trigger, snapshot);
recordAdviceSent(groupId, trigger.tier);
database.adviceLogs.create({
group_id: groupId,
tier: trigger.tier,
trigger_type: trigger.type,
trigger_data: JSON.stringify(trigger.data),
topic: trigger.topic,
advice_text: '[auto-advice suppressed]',
});
} catch (error) {
logger.error({ err: error }, '[ADVICE] Error in smart advice check');
}
Expand Down Expand Up @@ -361,8 +403,6 @@ async function sendSmartAdvice(
);
}

// Record advice in log and update cooldown
recordAdviceSent(groupId, tier);
database.adviceLogs.create({
group_id: groupId,
tier,
Expand Down
20 changes: 20 additions & 0 deletions src/bot/services/expense-saver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,28 @@ async function checkBudgetLimit(
let message = '';

if (progress.is_exceeded) {
// Dedup: send once per month per category so repeated expenses don't spam.
const topic = `budget_threshold:${category}:exceeded`;
const monthStartISO = `${monthStart}T00:00:00`;
if (database.adviceLogs.hasTopicThisMonth(groupId, topic, monthStartISO)) return;

message = `🔴 ПРЕВЫШЕН БЮДЖЕТ!\n`;
message += `${emoji} ${category}: ${progressText}`;

// Write before send to prevent race condition with concurrent expense additions.
database.adviceLogs.create({
group_id: groupId,
tier: 'alert',
trigger_type: 'budget_threshold',
trigger_data: JSON.stringify({
category,
spent: spentInCurrency,
limit: budget.limit_amount,
currency: budgetCurrency,
}),
topic,
advice_text: message,
});
} else if (progress.is_warning) {
message = `⚠️ Внимание! Приближение к лимиту бюджета:\n`;
message += `${emoji} ${category}: ${progressText}`;
Expand Down
2 changes: 2 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface EnvConfig {
BOT_ADMIN_CHAT_ID: number | null;
LARGE_TX_THRESHOLD_EUR: number;
AI_DEBUG_LOGS: boolean;
AUTO_ADVICE_ENABLED: boolean;
NODE_ENV: 'development' | 'production';
MINIAPP_URL: string | undefined;
MINIAPP_SHORTNAME: string | undefined;
Expand Down Expand Up @@ -89,6 +90,7 @@ function validateEnv(): EnvConfig {
: null,
LARGE_TX_THRESHOLD_EUR: parseInt(process.env['LARGE_TX_THRESHOLD_EUR'] || '100', 10),
AI_DEBUG_LOGS: process.env['AI_DEBUG_LOGS'] === 'true',
AUTO_ADVICE_ENABLED: process.env['AUTO_ADVICE_ENABLED'] === 'true',
NODE_ENV: (process.env.NODE_ENV as 'development' | 'production') || 'development',
MINIAPP_URL: getEnvVariable('MINIAPP_URL', false) || undefined,
MINIAPP_SHORTNAME: getEnvVariable('MINIAPP_SHORTNAME', false) || undefined,
Expand Down
Loading
Loading