Skip to content

Commit 790dcc9

Browse files
committed
fix: improve explain insights
1 parent ce1f7a9 commit 790dcc9

3 files changed

Lines changed: 179 additions & 9 deletions

File tree

packages/ai/src/explain.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,27 @@ export interface BreakdownComparison {
2929
baseline: { name: string | null; sessions: number }[];
3030
}
3131

32+
// Per-day sessions for the insight's own segment, so the model can read the
33+
// *shape* of the change (one-off spike on a date vs sustained growth) rather
34+
// than just current-vs-baseline totals.
35+
export interface DailyPoint {
36+
date: string;
37+
sessions: number;
38+
}
39+
3240
export interface ExplainInsightInput {
3341
insight: {
3442
title: string;
3543
dimension: string;
3644
window: string;
3745
summary?: string;
3846
};
47+
// The insight metric's daily series for its own segment.
48+
dailySeries?: {
49+
metric: string;
50+
current: DailyPoint[];
51+
baseline: DailyPoint[];
52+
};
3953
breakdowns: BreakdownComparison[];
4054
references: { title: string; date: string }[];
4155
}
@@ -66,15 +80,21 @@ const explanationJsonSchema = z.toJSONSchema(explanationSchema, {
6680

6781
const INSTRUCTION = `You explain WHY an analytics metric changed for a website/product owner.
6882
69-
You receive: the insight (what changed), a decomposition of the change across dimensions (referrer, country, device, utm_source) as current-window vs baseline-window session breakdowns, and any manual references (off-platform events the owner logged) near the window.
83+
You receive: the insight (what changed); a dailySeries — the segment's own per-day values for the current window vs the baseline window; a decomposition of the change across dimensions (referrer, country, device, utm_source) as current-vs-baseline session breakdowns; and any manual references (off-platform events the owner logged) near the window.
84+
85+
Read the dailySeries FIRST — it tells you the *shape* of the change, which the totals alone hide:
86+
- A one-off spike: most of the change is concentrated in one or a few days, then it returns toward baseline. Say so explicitly and name the peak date(s) and the peak value. A single-day spike inflating a window total is NOT sustained growth — do not describe it as "grew to X" as if it held.
87+
- A step change: it jumps to a new level and stays there.
88+
- Sustained/gradual growth or decline: it moves steadily across the window.
89+
If the series is flat except for a spike, lead with the spike.
7090
7191
Produce:
72-
- summary: 1-2 plain sentences answering "why did this happen", grounded in the decomposition. Name the sub-segment(s) that account for most of the change (e.g. "most of the lift came from reddit.com referrals").
73-
- drivers: the concrete contributors, each a short label + a one-line detail with the numbers.
74-
- relatedReference: the title of a reference that plausibly explains the change, or "" if none fits. Do not force a connection.
75-
- confidence: low | medium | high — how clearly the decomposition explains the change.
92+
- summary: 1-2 plain sentences answering "why did this happen", grounded in the data. State the shape (spike / step / sustained) with the peak date when it's a spike, then name the sub-segment(s) that account for most of the change (e.g. "a one-off spike on May 28 — ~60 sessions vs a ~5/day baseline — drove the lift; traffic has since returned to baseline").
93+
- drivers: the concrete contributors, each a short label + a one-line detail with the numbers (include the peak date/value when relevant).
94+
- relatedReference: the title of a reference whose date lines up with the spike/change date, or "" if none fits. Prefer a reference dated on or just before the peak day. Do not force a connection.
95+
- confidence: low | medium | high — how clearly the data explains the change.
7696
77-
Be honest and precise. You can only see what's in the data: explain the internal decomposition (which segment moved), not external causes you can't observe. If the breakdown doesn't clearly explain it, say so and set confidence low. Never invent numbers.`;
97+
Be honest and precise. You can only see what's in the data: explain the shape and the internal decomposition (which segment moved, when), not external causes you can't observe. If the data doesn't clearly explain it, say so and set confidence low. Never invent numbers or dates.`;
7898

7999
let _app: ReturnType<typeof betterAgent> | null = null;
80100
function getApp() {

packages/db/src/services/overview.service.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,72 @@ export async function getTrafficBreakdownCore(input: {
15381538
});
15391539
}
15401540

1541+
// Columns whose daily series we can derive from the sessions table. Page/entry
1542+
// insights (path/origin) live on the events table and aren't covered here — the
1543+
// caller degrades to no series for those.
1544+
const SEGMENT_SERIES_COLUMNS: ReadonlySet<string> = new Set<TrafficColumn>([
1545+
'referrer',
1546+
'referrer_name',
1547+
'referrer_type',
1548+
'utm_source',
1549+
'utm_medium',
1550+
'utm_campaign',
1551+
'country',
1552+
'region',
1553+
'city',
1554+
'device',
1555+
'browser',
1556+
'os',
1557+
]);
1558+
1559+
export interface SegmentDailyPoint {
1560+
date: string;
1561+
sessions: number;
1562+
pageviews: number;
1563+
}
1564+
1565+
// Daily breakdown for a single segment value (e.g. the "Twitter" referrer),
1566+
// so the explainer can see the *shape* of a change (one-off spike vs sustained
1567+
// growth) instead of only current-vs-baseline totals. Returns one point per day
1568+
// with zero-filled gaps; empty when the column isn't session-derived or the
1569+
// value never appears in the window.
1570+
export async function getSegmentDailySeriesCore(input: {
1571+
projectId: string;
1572+
column: string;
1573+
value: string;
1574+
startDate: string;
1575+
endDate: string;
1576+
}): Promise<SegmentDailyPoint[]> {
1577+
if (!SEGMENT_SERIES_COLUMNS.has(input.column)) {
1578+
return [];
1579+
}
1580+
1581+
const { timezone } = await getSettingsForProject(input.projectId);
1582+
const { items } = await overviewService.getTopGenericSeries({
1583+
projectId: input.projectId,
1584+
filters: [],
1585+
startDate: input.startDate,
1586+
endDate: input.endDate,
1587+
column: input.column as TrafficColumn,
1588+
interval: 'day',
1589+
timezone,
1590+
});
1591+
1592+
// getTopGenericSeries reports empty values as null name; insights store the
1593+
// empty referrer as "direct". Match the segment leniently.
1594+
const target = input.value.toLowerCase();
1595+
const matched = items.find((item) => {
1596+
const name = (item.name ?? '').toLowerCase();
1597+
return name === target || (name === '' && target === 'direct');
1598+
});
1599+
1600+
return (matched?.data ?? []).map((point) => ({
1601+
date: point.date,
1602+
sessions: Number(point.sessions ?? 0),
1603+
pageviews: Number(point.pageviews ?? 0),
1604+
}));
1605+
}
1606+
15411607
export interface GetAnalyticsOverviewInput {
15421608
projectId: string;
15431609
startDate: string;

packages/trpc/src/routers/insight.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import { generateInsightExplanation } from '@openpanel/ai';
2-
import { db, getTrafficBreakdownCore } from '@openpanel/db';
2+
import {
3+
db,
4+
getSegmentDailySeriesCore,
5+
getTrafficBreakdownCore,
6+
} from '@openpanel/db';
7+
import { getRedisCache } from '@openpanel/redis';
8+
import type { InsightPayload } from '@openpanel/validation';
39
import { z } from 'zod';
410
import { getProjectAccess } from '../access';
511
import { TRPCForbiddenError } from '../errors';
612
import { createTRPCRouter, protectedProcedure } from '../trpc';
713

814
const DAY_MS = 24 * 60 * 60 * 1000;
15+
// The explanation is a paid LLM call. Cache it keyed by the insight's
16+
// lastUpdatedAt so repeat clicks within the window are free, while any recompute
17+
// that changes the insight produces a new key and a fresh explanation.
18+
const EXPLAIN_CACHE_TTL_SEC = 24 * 60 * 60;
919
const EXPLAIN_COLUMNS = [
1020
'referrer_name',
1121
'country',
@@ -117,7 +127,8 @@ export const insightRouter = createTRPCRouter({
117127

118128
// Phase 5: the "why". Decompose the insight's change across referrer/country/
119129
// device/utm (current vs baseline window), pull nearby references, and have
120-
// the AI explain which sub-segment drove it. On-demand (a button), not cached.
130+
// the AI explain which sub-segment drove it. On-demand (a button); the result
131+
// is cached per insight version so repeat clicks don't re-bill the LLM.
121132
explain: protectedProcedure
122133
.input(z.object({ insightId: z.string() }))
123134
.mutation(async ({ input: { insightId }, ctx }) => {
@@ -132,6 +143,8 @@ export const insightRouter = createTRPCRouter({
132143
windowKind: true,
133144
windowStart: true,
134145
windowEnd: true,
146+
payload: true,
147+
lastUpdatedAt: true,
135148
},
136149
});
137150

@@ -144,6 +157,16 @@ export const insightRouter = createTRPCRouter({
144157
throw new TRPCForbiddenError('You do not have access to this project');
145158
}
146159

160+
// Serve a cached explanation if the insight hasn't changed since we
161+
// computed it. Skips both the ClickHouse queries and the LLM call.
162+
const cacheKey = `insight-explain:${insightId}:${insight.lastUpdatedAt.getTime()}`;
163+
const cached = await getRedisCache().get(cacheKey);
164+
if (cached) {
165+
return JSON.parse(cached) as Awaited<
166+
ReturnType<typeof generateInsightExplanation>
167+
>;
168+
}
169+
147170
// Current window from the insight; baseline = same span immediately before.
148171
const end = insight.windowEnd ?? new Date();
149172
const start =
@@ -178,6 +201,54 @@ export const insightRouter = createTRPCRouter({
178201
}),
179202
);
180203

204+
// Daily series for the insight's own segment, so the model can read the
205+
// shape of the change (a one-off spike vs sustained growth) instead of
206+
// only the current-vs-baseline totals. Best-effort: skip on page/entry
207+
// insights (events-table metrics) or anything we can't resolve.
208+
const payload = insight.payload as InsightPayload | null;
209+
const segment = payload?.dimensions?.[0];
210+
const primaryMetric = payload?.primaryMetric ?? 'sessions';
211+
let dailySeries:
212+
| {
213+
metric: string;
214+
current: { date: string; sessions: number }[];
215+
baseline: { date: string; sessions: number }[];
216+
}
217+
| undefined;
218+
219+
if (segment?.key && segment.value) {
220+
const [curSeries, baseSeries] = await Promise.all([
221+
getSegmentDailySeriesCore({
222+
projectId: insight.projectId,
223+
column: segment.key,
224+
value: segment.value,
225+
startDate: iso(start),
226+
endDate: iso(end),
227+
}),
228+
getSegmentDailySeriesCore({
229+
projectId: insight.projectId,
230+
column: segment.key,
231+
value: segment.value,
232+
startDate: iso(baseStart),
233+
endDate: iso(baseEnd),
234+
}),
235+
]);
236+
237+
const toMetric = (points: typeof curSeries) =>
238+
points.map((p) => ({
239+
date: p.date.slice(0, 10),
240+
sessions: primaryMetric === 'pageviews' ? p.pageviews : p.sessions,
241+
}));
242+
243+
if (curSeries.length > 0) {
244+
dailySeries = {
245+
metric: primaryMetric,
246+
current: toMetric(curSeries),
247+
baseline: toMetric(baseSeries),
248+
};
249+
}
250+
}
251+
181252
const references = await db.reference.findMany({
182253
where: {
183254
projectId: insight.projectId,
@@ -191,18 +262,31 @@ export const insightRouter = createTRPCRouter({
191262
select: { title: true, date: true },
192263
});
193264

194-
return generateInsightExplanation({
265+
const explanation = await generateInsightExplanation({
195266
insight: {
196267
title: insight.aiSummary ?? insight.title,
197268
dimension: insight.dimensionKey,
198269
window: insight.windowKind,
199270
summary: insight.summary ?? undefined,
200271
},
272+
dailySeries,
201273
breakdowns,
202274
references: references.map((r) => ({
203275
title: r.title,
204276
date: r.date.toISOString().slice(0, 10),
205277
})),
206278
});
279+
280+
// Only cache a successful explanation — a null is a transient LLM failure
281+
// and should be retried on the next click.
282+
if (explanation) {
283+
await getRedisCache().setex(
284+
cacheKey,
285+
EXPLAIN_CACHE_TTL_SEC,
286+
JSON.stringify(explanation),
287+
);
288+
}
289+
290+
return explanation;
207291
}),
208292
});

0 commit comments

Comments
 (0)