Previously the advisor always analyzed the current calendar month and
compared it against the previous calendar month, regardless of which
range the user had selected on the dashboard. That meant clicking
"Last 90 days" updated the cards but the advice was still scoped to
this month — the two surfaces disagreed on what "now" meant.
The chat payload now carries an optional period { startDate, endDate,
label } that the dashboard derives from its existing range state. When
present, the service uses that window as the current period and the
equal-length window immediately before it as the comparison period
(so "Last 30 days" pairs naturally with the prior 30 days). Period
labels flow into the prompt sections so the model talks about the
window the user is actually looking at. The legacy /advisor/insights
GET endpoint and any caller that omits the period keep the original
this-month / last-month behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,10 +64,35 @@ describe('AdvisorController', () => {
|
||||
|
||||
const result = await controller.chat(mockUser, body);
|
||||
|
||||
expect(mockService.chat).toHaveBeenCalledWith('user-123', body.messages);
|
||||
expect(mockService.chat).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
body.messages,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Dining Out was $450 this month — down $50 vs last month.',
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /chat forwards the optional dashboard period to the service', async () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: 'user' as const, content: 'What about dining?' },
|
||||
],
|
||||
period: {
|
||||
startDate: '2026-04-02',
|
||||
endDate: '2026-05-01',
|
||||
label: 'Last 30 days',
|
||||
},
|
||||
};
|
||||
|
||||
await controller.chat(mockUser, body);
|
||||
|
||||
expect(mockService.chat).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
body.messages,
|
||||
body.period,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,6 @@ export class AdvisorController {
|
||||
|
||||
@Post('chat')
|
||||
chat(@CurrentUser() user: User, @Body() body: ChatRequestDto) {
|
||||
return this.advisorService.chat(user.id, body.messages);
|
||||
return this.advisorService.chat(user.id, body.messages, body.period);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,18 +163,21 @@ describe('AdvisorService', () => {
|
||||
expect(systemMessage.content).toMatch(/last month/i);
|
||||
});
|
||||
|
||||
it('pre-computes "Saved this month" so the model does not have to derive it', async () => {
|
||||
// Mock returns income 5000 / expense 3200 → saved = 1,800
|
||||
it('pre-computes the saved amount so the model does not have to derive it', async () => {
|
||||
// Mock returns income 5000 / expense 3200 → saved = 1,800 for both
|
||||
// periods, so we expect the value to appear at least twice in the prompt.
|
||||
mockOllamaOnce('opening analysis');
|
||||
await service.getAdvice(userId);
|
||||
|
||||
const body = getFetchBody();
|
||||
const systemMessage = body.messages.find((m: any) => m.role === 'system');
|
||||
expect(systemMessage.content).toMatch(/saved this month: \$1,800\.00/i);
|
||||
expect(systemMessage.content).toMatch(/saved last month: \$1,800\.00/i);
|
||||
const matches = (systemMessage.content as string).match(
|
||||
/saved:?\s*\$1,800\.00/gi,
|
||||
);
|
||||
expect(matches?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('separates point-in-time standing balance from monthly flow numbers', async () => {
|
||||
it('separates point-in-time standing balance from period-flow numbers', async () => {
|
||||
mockOllamaOnce('opening analysis');
|
||||
await service.getAdvice(userId);
|
||||
|
||||
@@ -186,10 +189,10 @@ describe('AdvisorService', () => {
|
||||
expect(content).toMatch(/standing balance/i);
|
||||
expect(content).toMatch(/this month/i);
|
||||
|
||||
// Standing-balance figures appear before the monthly-flow figures so the
|
||||
// Standing-balance figures appear before the period-flow figures so the
|
||||
// model can't conflate them as "savings = netWorth - totalDebt".
|
||||
const standingIdx = content.search(/standing balance/i);
|
||||
const flowIdx = content.search(/this month so far/i);
|
||||
const flowIdx = content.search(/this month \(flow\)/i);
|
||||
expect(standingIdx).toBeGreaterThan(-1);
|
||||
expect(flowIdx).toBeGreaterThan(standingIdx);
|
||||
});
|
||||
@@ -343,6 +346,78 @@ describe('AdvisorService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard period support', () => {
|
||||
it('fetches aggregations for the dashboard-selected period when provided', async () => {
|
||||
mockOllamaOnce('opening analysis');
|
||||
await service.chat(userId, [], {
|
||||
startDate: '2026-04-02',
|
||||
endDate: '2026-05-01',
|
||||
label: 'Last 30 days',
|
||||
});
|
||||
|
||||
// Current period: Apr 2 → May 1 (30 days, inclusive)
|
||||
expect(mockAggregations.getSummary).toHaveBeenCalledWith(
|
||||
userId,
|
||||
'2026-04-02',
|
||||
'2026-05-01',
|
||||
);
|
||||
expect(mockAggregations.getSpendingByCategory).toHaveBeenCalledWith(
|
||||
userId,
|
||||
'2026-04-02',
|
||||
'2026-05-01',
|
||||
);
|
||||
});
|
||||
|
||||
it('compares against the equal-length window immediately before the selected period', async () => {
|
||||
mockOllamaOnce('opening analysis');
|
||||
await service.chat(userId, [], {
|
||||
startDate: '2026-04-02',
|
||||
endDate: '2026-05-01',
|
||||
label: 'Last 30 days',
|
||||
});
|
||||
|
||||
// 30-day window ending Apr 1: Mar 3 → Apr 1
|
||||
expect(mockAggregations.getSummary).toHaveBeenCalledWith(
|
||||
userId,
|
||||
'2026-03-03',
|
||||
'2026-04-01',
|
||||
);
|
||||
expect(mockAggregations.getSpendingByCategory).toHaveBeenCalledWith(
|
||||
userId,
|
||||
'2026-03-03',
|
||||
'2026-04-01',
|
||||
);
|
||||
});
|
||||
|
||||
it('puts the selected period label into the system prompt', async () => {
|
||||
mockOllamaOnce('opening analysis');
|
||||
await service.chat(userId, [], {
|
||||
startDate: '2026-02-01',
|
||||
endDate: '2026-05-01',
|
||||
label: 'Last 90 days',
|
||||
});
|
||||
|
||||
const body = getFetchBody();
|
||||
const systemMessage = body.messages.find((m: any) => m.role === 'system');
|
||||
expect(systemMessage.content).toMatch(/last 90 days/i);
|
||||
// Default labels must NOT leak when a custom period is in use, otherwise
|
||||
// the model has two competing labels for the same numbers.
|
||||
expect(systemMessage.content).not.toMatch(/this month \(flow\)/i);
|
||||
});
|
||||
|
||||
it('falls back to current/last calendar month when no period is provided', async () => {
|
||||
// Default behavior is unchanged for callers (e.g. the legacy
|
||||
// /advisor/insights GET endpoint) that don't pass a period.
|
||||
mockOllamaOnce('opening analysis');
|
||||
await service.chat(userId, []);
|
||||
|
||||
const body = getFetchBody();
|
||||
const systemMessage = body.messages.find((m: any) => m.role === 'system');
|
||||
expect(systemMessage.content).toMatch(/this month/i);
|
||||
expect(systemMessage.content).toMatch(/last month/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system prompt anti-math hardening', () => {
|
||||
it('puts the no-math rule near the top of the prompt', async () => {
|
||||
mockOllamaOnce('opening analysis');
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface MonthContext {
|
||||
interface PeriodContext {
|
||||
summary: {
|
||||
netWorth: number;
|
||||
totalDebt: number;
|
||||
@@ -24,6 +24,13 @@ interface MonthContext {
|
||||
expense: number;
|
||||
};
|
||||
topCategories: { name?: string; amount: number }[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AdvisorPeriod {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -54,8 +61,8 @@ export class AdvisorService {
|
||||
return data;
|
||||
}
|
||||
|
||||
async getAdvice(userId: string) {
|
||||
const reply = await this.chat(userId, []);
|
||||
async getAdvice(userId: string, period?: AdvisorPeriod) {
|
||||
const reply = await this.chat(userId, [], period);
|
||||
return {
|
||||
insights: reply.content,
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -65,8 +72,9 @@ export class AdvisorService {
|
||||
async chat(
|
||||
userId: string,
|
||||
clientMessages: ChatMessage[],
|
||||
period?: AdvisorPeriod,
|
||||
): Promise<ChatMessage> {
|
||||
const context = await this.buildContext(userId);
|
||||
const context = await this.buildContext(userId, period);
|
||||
const systemPrompt = this.buildSystemPrompt(context);
|
||||
|
||||
// Chat models won't produce a reply from a system message alone — they
|
||||
@@ -130,11 +138,44 @@ export class AdvisorService {
|
||||
return { start: toIso(start), end: toIso(end) };
|
||||
}
|
||||
|
||||
// Equal-length window immediately before [start, end]. Used so that the
|
||||
// advisor can compare a custom dashboard range (e.g. "Last 30 days") to
|
||||
// the prior 30 days, not to the previous calendar month.
|
||||
private previousPeriod(
|
||||
start: string,
|
||||
end: string,
|
||||
): { start: string; end: string } {
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const s = new Date(`${start}T00:00:00.000Z`).getTime();
|
||||
const e = new Date(`${end}T00:00:00.000Z`).getTime();
|
||||
const days = Math.round((e - s) / dayMs) + 1;
|
||||
const prevEnd = new Date(s - dayMs);
|
||||
const prevStart = new Date(s - days * dayMs);
|
||||
const toIso = (d: Date) => d.toISOString().split('T')[0];
|
||||
return { start: toIso(prevStart), end: toIso(prevEnd) };
|
||||
}
|
||||
|
||||
private async buildContext(
|
||||
userId: string,
|
||||
): Promise<{ current: MonthContext; previous: MonthContext }> {
|
||||
const cur = this.monthBounds(0);
|
||||
const prev = this.monthBounds(-1);
|
||||
period?: AdvisorPeriod,
|
||||
): Promise<{ current: PeriodContext; previous: PeriodContext }> {
|
||||
let cur: { start: string; end: string; label: string };
|
||||
let prev: { start: string; end: string; label: string };
|
||||
|
||||
if (period) {
|
||||
cur = {
|
||||
start: period.startDate,
|
||||
end: period.endDate,
|
||||
label: period.label,
|
||||
};
|
||||
const prevBounds = this.previousPeriod(period.startDate, period.endDate);
|
||||
prev = { ...prevBounds, label: 'Previous period' };
|
||||
} else {
|
||||
const curBounds = this.monthBounds(0);
|
||||
const prevBounds = this.monthBounds(-1);
|
||||
cur = { ...curBounds, label: 'This month' };
|
||||
prev = { ...prevBounds, label: 'Last month' };
|
||||
}
|
||||
|
||||
const [curSummary, curCats, prevSummary, prevCats] = await Promise.all([
|
||||
this.aggregations.getSummary(userId, cur.start, cur.end),
|
||||
@@ -147,17 +188,19 @@ export class AdvisorService {
|
||||
current: {
|
||||
summary: curSummary,
|
||||
topCategories: curCats.slice(0, 3),
|
||||
label: cur.label,
|
||||
},
|
||||
previous: {
|
||||
summary: prevSummary,
|
||||
topCategories: prevCats.slice(0, 3),
|
||||
label: prev.label,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildSystemPrompt(ctx: {
|
||||
current: MonthContext;
|
||||
previous: MonthContext;
|
||||
current: PeriodContext;
|
||||
previous: PeriodContext;
|
||||
}): string {
|
||||
const fmt = (n: number) =>
|
||||
Number(n).toLocaleString('en-US', {
|
||||
@@ -165,12 +208,12 @@ export class AdvisorService {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const savingsRate = (s: MonthContext['summary']) =>
|
||||
const savingsRate = (s: PeriodContext['summary']) =>
|
||||
s.income > 0
|
||||
? ((1 - s.expense / s.income) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
const renderCats = (cats: MonthContext['topCategories']) =>
|
||||
const renderCats = (cats: PeriodContext['topCategories']) =>
|
||||
cats.length
|
||||
? cats
|
||||
.map((c) => ` - ${c.name ?? 'Uncategorized'}: $${fmt(c.amount)}`)
|
||||
@@ -184,42 +227,43 @@ export class AdvisorService {
|
||||
const expenseDelta = Number((cur.expense - prev.expense).toFixed(2));
|
||||
const savedMore = -expenseDelta;
|
||||
|
||||
const prevLabelLower = ctx.previous.label.toLowerCase();
|
||||
const trendLine =
|
||||
prev.expense === 0
|
||||
? 'No prior-month data to compare yet.'
|
||||
? 'No prior-period data to compare yet.'
|
||||
: savedMore > 0
|
||||
? `Spending is down $${fmt(savedMore)} vs last month — nice trend.`
|
||||
? `Spending is down $${fmt(savedMore)} vs ${prevLabelLower} — nice trend.`
|
||||
: savedMore < 0
|
||||
? `Spending is up $${fmt(Math.abs(savedMore))} vs last month.`
|
||||
: 'Spending is flat vs last month.';
|
||||
? `Spending is up $${fmt(Math.abs(savedMore))} vs ${prevLabelLower}.`
|
||||
: `Spending is flat vs ${prevLabelLower}.`;
|
||||
|
||||
return `CRITICAL — never write math:
|
||||
- Use only the dollar figures listed verbatim below. If a number isn't listed, do not mention it. Never derive averages, projections, ratios, or totals that aren't already labeled below.
|
||||
- Never append parenthetical arithmetic to your sentences. Forbidden examples (do NOT produce these): "($749.51 / 0.267)", "($18,952.39 - $83,276.19)", "($X * Y)". You will get the math wrong, and the user has explicitly asked us not to show our work.
|
||||
- Never subtract or divide values from different sections to make a new number.
|
||||
|
||||
You're a friendly financial buddy — talk like a supportive friend, not a corporate advisor. Lead with the single most important thing the user should know: either a specific win worth celebrating or a specific concern worth addressing. Reference exact dollar amounts from the numbers below, compare this month to last month when the numbers tell a story, and keep replies conversational and short (no rigid numbered lists unless the user asks for one). Use first person ("I see...", "you're...").
|
||||
You're a friendly financial buddy — talk like a supportive friend, not a corporate advisor. Lead with the single most important thing the user should know: either a specific win worth celebrating or a specific concern worth addressing. Reference exact dollar amounts from the numbers below, compare ${ctx.current.label.toLowerCase()} to ${prevLabelLower} when the numbers tell a story, and keep replies conversational and short (no rigid numbered lists unless the user asks for one). Use first person ("I see...", "you're...").
|
||||
|
||||
How to read the numbers:
|
||||
- "Standing balance" numbers are point-in-time totals (what the user has and owes right now). They are NOT this month's savings or activity. Never subtract them or use them as monthly flow.
|
||||
- "This month" / "Last month" numbers are flow — money that moved during that calendar month. Use "Saved this month" when talking about how much was set aside.
|
||||
- "Standing balance" numbers are point-in-time totals (what the user has and owes right now). They are NOT savings or activity for any period. Never subtract them or use them as flow.
|
||||
- "${ctx.current.label}" and "${ctx.previous.label}" numbers are flow — money that moved during that window. The "${ctx.current.label}" window is the one the user is currently looking at on the dashboard.
|
||||
|
||||
Standing balance (point-in-time, not flow):
|
||||
- Net worth: $${fmt(cur.netWorth)}
|
||||
- Total debt: $${fmt(Math.abs(cur.totalDebt))}
|
||||
|
||||
This month so far (flow):
|
||||
${ctx.current.label} (flow):
|
||||
- Income: $${fmt(cur.income)}
|
||||
- Expenses: $${fmt(cur.expense)}
|
||||
- Saved this month: $${fmt(curSaved)}
|
||||
- Saved: $${fmt(curSaved)}
|
||||
- Savings rate: ${savingsRate(cur)}%
|
||||
- Top spending categories:
|
||||
${renderCats(ctx.current.topCategories)}
|
||||
|
||||
Last month (flow):
|
||||
${ctx.previous.label} (flow):
|
||||
- Income: $${fmt(prev.income)}
|
||||
- Expenses: $${fmt(prev.expense)}
|
||||
- Saved last month: $${fmt(prevSaved)}
|
||||
- Saved: $${fmt(prevSaved)}
|
||||
- Savings rate: ${savingsRate(prev)}%
|
||||
- Top spending categories:
|
||||
${renderCats(ctx.previous.topCategories)}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsArray, IsIn, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ChatMessageDto {
|
||||
@@ -9,9 +15,25 @@ export class ChatMessageDto {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class AdvisorPeriodDto {
|
||||
@IsString()
|
||||
startDate: string;
|
||||
|
||||
@IsString()
|
||||
endDate: string;
|
||||
|
||||
@IsString()
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class ChatRequestDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages: ChatMessageDto[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => AdvisorPeriodDto)
|
||||
period?: AdvisorPeriodDto;
|
||||
}
|
||||
|
||||
@@ -126,14 +126,27 @@ export function Dashboard() {
|
||||
}
|
||||
}, [advisorMessages]);
|
||||
|
||||
const handleRestart = async () => {
|
||||
resetConversation();
|
||||
await startConversation();
|
||||
};
|
||||
|
||||
const [range, setRange] = useState<RangeKey>('this-month');
|
||||
const dateRange = useMemo(() => computeRange(range), [range]);
|
||||
|
||||
const advisorPeriod = useMemo(
|
||||
() => ({
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
label: RANGE_LABELS[range],
|
||||
}),
|
||||
[dateRange, range],
|
||||
);
|
||||
|
||||
const handleRestart = async () => {
|
||||
resetConversation();
|
||||
await startConversation(advisorPeriod);
|
||||
};
|
||||
|
||||
const handleStartConversation = () => startConversation(advisorPeriod);
|
||||
const handleSendMessage = (content: string) =>
|
||||
sendMessage(content, advisorPeriod);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary(dateRange.startDate, dateRange.endDate);
|
||||
fetchSpendingByCategory(dateRange.startDate, dateRange.endDate);
|
||||
@@ -409,7 +422,7 @@ export function Dashboard() {
|
||||
Get a conversational read on how your month is going, then ask
|
||||
follow-up questions for deeper guidance.
|
||||
</p>
|
||||
<Button onClick={startConversation} disabled={advisorLoading}>
|
||||
<Button onClick={handleStartConversation} disabled={advisorLoading}>
|
||||
<Sparkles className="mr-1 size-4" />
|
||||
{advisorLoading ? 'Analyzing...' : 'Get Advice'}
|
||||
</Button>
|
||||
@@ -448,7 +461,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
<AdvisorFollowUpForm
|
||||
disabled={advisorLoading}
|
||||
onSubmit={sendMessage}
|
||||
onSubmit={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,17 +6,29 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AdvisorPeriod {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AdvisorState {
|
||||
messages: ChatMessage[];
|
||||
generatedAt: string | null;
|
||||
loading: boolean;
|
||||
startConversation: () => Promise<void>;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
startConversation: (period?: AdvisorPeriod) => Promise<void>;
|
||||
sendMessage: (content: string, period?: AdvisorPeriod) => Promise<void>;
|
||||
resetConversation: () => void;
|
||||
}
|
||||
|
||||
async function callChat(messages: ChatMessage[]): Promise<ChatMessage> {
|
||||
return api.post<ChatMessage>('/advisor/chat', { messages });
|
||||
async function callChat(
|
||||
messages: ChatMessage[],
|
||||
period?: AdvisorPeriod,
|
||||
): Promise<ChatMessage> {
|
||||
return api.post<ChatMessage>('/advisor/chat', {
|
||||
messages,
|
||||
...(period ? { period } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export const useAdvisorStore = create<AdvisorState>((set, get) => ({
|
||||
@@ -24,10 +36,10 @@ export const useAdvisorStore = create<AdvisorState>((set, get) => ({
|
||||
generatedAt: null,
|
||||
loading: false,
|
||||
|
||||
startConversation: async () => {
|
||||
startConversation: async (period) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const reply = await callChat([]);
|
||||
const reply = await callChat([], period);
|
||||
set({
|
||||
messages: [reply],
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -39,12 +51,12 @@ export const useAdvisorStore = create<AdvisorState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content) => {
|
||||
sendMessage: async (content, period) => {
|
||||
const userMessage: ChatMessage = { role: 'user', content };
|
||||
const next = [...get().messages, userMessage];
|
||||
set({ messages: next, loading: true });
|
||||
try {
|
||||
const reply = await callChat(next);
|
||||
const reply = await callChat(next, period);
|
||||
set({ messages: [...next, reply], loading: false });
|
||||
} catch (err) {
|
||||
set({ loading: false });
|
||||
|
||||
Reference in New Issue
Block a user