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
12 changes: 10 additions & 2 deletions apps/insights/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,15 +617,23 @@ function validateDefinitionOutcome(
isSuccessfulRead(result.output)
) {
const parsed = z
.object({ measurement: insightMeasurementSchema })
.object({
measurement: insightMeasurementSchema,
savedDefinition:
insightMeasurementSchema.shape.definition.optional(),
})
.safeParse(result.output);
if (
parsed.success &&
parsed.data.measurement.definitionId === entity.id &&
parsed.data.measurement.websiteId ===
(input.appContext.websiteId ?? input.appContext.defaultWebsiteId)
) {
current = { id: entity.id, ...parsed.data.measurement.definition };
current = {
id: entity.id,
...(parsed.data.savedDefinition ??
parsed.data.measurement.definition),
};
}
}
if (result.toolName !== listTool || !isSuccessfulRead(result.output)) {
Expand Down
107 changes: 65 additions & 42 deletions apps/insights/src/investigation-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ describe("intelligence agent", () => {
"valid",
"native-only",
"native-after-list",
"native-cohort",
"native-lost-conditions",
"uninspected-check",
"unanchored",
Expand All @@ -598,6 +599,12 @@ describe("intelligence agent", () => {
...(native ? { conditions: { plan: "paid" } } : {}),
})),
};
let thresholdValue = 20;
if (scenario === "unanchored") {
thresholdValue = 99;
} else if (scenario === "wrong-units") {
thresholdValue = 120;
}
const check = {
metric: "overall_conversion_rate" as const,
startDate: scenario === "past-window" ? "2026-07-01" : "2026-07-13",
Expand All @@ -606,12 +613,7 @@ describe("intelligence agent", () => {
threshold: {
anchor: "prior_baseline" as const,
comparison: "at_or_above" as const,
value:
scenario === "unanchored"
? 99
: scenario === "wrong-units"
? 120
: 20,
value: thresholdValue,
evidenceRef: { source: "signal" as const },
},
};
Expand All @@ -636,6 +638,42 @@ describe("intelligence agent", () => {
: {}),
},
};
const inspectionResponses: ReturnType<typeof toolCallsResponse>[] = [];
if (scenario === "native-only" || scenario === "native-cohort") {
inspectionResponses.push(toolCallsResponse(["get_funnel_analytics"]));
} else if (scenario === "native-after-list") {
inspectionResponses.push(
toolCallsResponse(["list_funnels"]),
toolCallsResponse(["get_funnel_analytics"])
);
} else if (scenario !== "uninspected-check") {
inspectionResponses.push(
toolCallsResponse(["list_funnels", "get_funnel_analytics"])
);
}
const savedDefinition =
scenario === "native-cohort" ? { savedDefinition: current } : {};
const measuredDefinition =
scenario === "native-cohort"
? {
...current,
filters: [
{ field: "browser_name", operator: "equals", value: "Safari" },
],
}
: current;
const nativeMeasurement = native
? {
...savedDefinition,
measurement: {
websiteId: "site-1",
definitionId: "checkout",
startDate: "2026-07-05",
endDate: "2026-07-11",
definition: measuredDefinition,
},
}
: {};
const run = runInsightAgent(
{
appContext: appContext(),
Expand All @@ -648,18 +686,7 @@ describe("intelligence agent", () => {
{
model: new MockLanguageModelV3({
doGenerate: mockValues(
...(scenario === "uninspected-check"
? []
: scenario === "native-only"
? [toolCallsResponse(["get_funnel_analytics"])]
: scenario === "native-after-list"
? [
toolCallsResponse(["list_funnels"]),
toolCallsResponse(["get_funnel_analytics"]),
]
: [
toolCallsResponse(["list_funnels", "get_funnel_analytics"]),
]),
...inspectionResponses,
outputResponse(proposal),
outputResponse(proposal),
outputResponse(proposal)
Expand All @@ -671,17 +698,7 @@ describe("intelligence agent", () => {
execute: () => ({
completions: 10,
entrants: 100,
...(native
? {
measurement: {
websiteId: "site-1",
definitionId: "checkout",
startDate: "2026-07-05",
endDate: "2026-07-11",
definition: current,
},
}
: {}),
...nativeMeasurement,
}),
inputSchema: z.object({}).strict(),
}),
Expand All @@ -695,25 +712,31 @@ describe("intelligence agent", () => {
);

if (
!["legacy", "valid", "native-only", "native-after-list"].includes(
scenario
)
![
"legacy",
"valid",
"native-only",
"native-after-list",
"native-cohort",
].includes(scenario)
) {
await expect(run).rejects.toThrow(
scenario === "uninspected-check"
? "Until the exact subject is verified"
: scenario === "native-lost-conditions"
? "preserve existing step conditions"
: scenario === "unanchored"
? "99"
: "Verification checks require"
);
let expectedError = "Verification checks require";
if (scenario === "uninspected-check") {
expectedError = "Until the exact subject is verified";
} else if (scenario === "native-lost-conditions") {
expectedError = "preserve existing step conditions";
} else if (scenario === "unanchored") {
expectedError = "99";
}
await expect(run).rejects.toThrow(expectedError);
return;
}
const result = await run;
const expectedExecution = {
...proposal.next.execution,
changes: native ? { steps: proposal.next.execution.changes.steps } : proposal.next.execution.changes,
changes: native
? { steps: proposal.next.execution.changes.steps }
: proposal.next.execution.changes,
};
expect(result.outcome.next).toEqual({
...proposal.next,
Expand Down
115 changes: 115 additions & 0 deletions packages/ai/src/ai/tools/cohort-read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { expect, spyOn, test } from "bun:test";
import { asSchema, type ToolExecutionOptions } from "ai";
import { analyticsCohortSchema } from "@databuddy/shared/analytics-filters";
import * as rpc from "./utils/rpc";
const { createFunnelTools } = await import("./funnels");
const { createGoalTools } = await import("./goals");
const options: ToolExecutionOptions = {
toolCallId: "synthetic-cohort",
messages: [],
experimental_context: {
websiteId: "synthetic-site",
websiteDomain: "example.invalid",
},
};
const cohort = {
filters: [
{
field: "browser_name" as const,
operator: "equals" as const,
value: "Safari",
},
],
};
test("native cohort reaches the existing RPC procedure", async () => {
const invoke = spyOn(rpc, "callRPCProcedure").mockResolvedValue({
synthetic: true,
});
try {
const dates = { startDate: "2026-08-22", endDate: "2026-08-28", cohort };
const funnel = createFunnelTools().get_funnel_analytics;
const goal = createGoalTools().get_goal_analytics;
if (!funnel.execute || !goal.execute) throw new Error("Missing executor");
await funnel.execute({ funnelId: "synthetic-funnel", ...dates }, options);
await goal.execute({ goalId: "synthetic-goal", ...dates }, options);
expect(
invoke.mock.calls.map(([router, method, input]) => ({
router,
method,
input,
}))
).toEqual([
{
router: "funnels",
method: "getAnalytics",
input: {
funnelId: "synthetic-funnel",
websiteId: "synthetic-site",
...dates,
},
},
{
router: "goals",
method: "getAnalytics",
input: {
goalId: "synthetic-goal",
websiteId: "synthetic-site",
...dates,
},
},
]);
} finally {
invoke.mockRestore();
}
});
test("cohort rejects tenant and step selectors", () => {
for (const field of [
"website_id",
"client_id",
"owner_id",
"path",
"event_name",
"browser_name OR 1=1",
])
expect(
analyticsCohortSchema.safeParse({
filters: [{ field, operator: "equals", value: "x" }],
}).success
).toBe(false);
expect(
analyticsCohortSchema.safeParse({
filters: [{ ...cohort.filters[0], target: "event" }],
}).success
).toBe(false);
});
test("inaccessible website never reaches RPC", async () => {
Comment thread
izadoesdev marked this conversation as resolved.
const tool = createFunnelTools().get_funnel_analytics;
if (!tool.execute) throw new Error("Missing executor");
const invoke = spyOn(rpc, "callRPCProcedure").mockResolvedValue({
synthetic: true,
});
try {
await expect(
tool.execute(
{
funnelId: "synthetic-funnel",
websiteId: "other-tenant",
startDate: "2026-08-22",
endDate: "2026-08-28",
cohort,
},
options
)
).rejects.toThrow("not in this workspace");
expect(invoke).not.toHaveBeenCalled();
} finally {
invoke.mockRestore();
}
});
test("model JSON schema exposes the read capability", () => {
const schema = asSchema(
createFunnelTools().get_funnel_analytics.inputSchema
).jsonSchema;
expect(JSON.stringify(schema)).toContain('"browser_name"');
expect(JSON.stringify(schema)).toContain('"cohort"');
});
12 changes: 7 additions & 5 deletions packages/ai/src/ai/tools/funnels.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { analyticsCohortSchema } from "@databuddy/shared/analytics-filters";
import { tool } from "ai";
import { analyticsDateRangeSchema } from "@databuddy/validation";
import { z } from "zod";
Expand All @@ -13,6 +14,7 @@ const logger = createToolLogger("Funnels Tools");
const funnelAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({
funnelId: z.string(),
websiteId: z.string().optional(),
cohort: analyticsCohortSchema.optional(),
});

export function createFunnelTools() {
Expand Down Expand Up @@ -45,10 +47,10 @@ export function createFunnelTools() {

const getFunnelAnalyticsTool = tool({
description:
"Funnel definition, measured dates and distinct visitor counts: entrants match the first step; completions reach every ordered step. These are visitors, not projects, occurrences or attempts. Reuse matching verified measurements; remeasure stale or conflicting context.",
"Funnel definition, measured dates and distinct visitor counts: entrants match the first step; completions reach every ordered step. These are visitors, not projects, occurrences or attempts. Optional cohort measures browser, device, country or campaign segments without editing the saved definition. Compare cohorts and periods with parallel calls. Reuse matching verified measurements; remeasure stale or conflicting context.",
inputSchema: funnelAnalyticsInputSchema,
execute: async (
{ funnelId, websiteId: inputWebsiteId, startDate, endDate },
{ funnelId, websiteId: inputWebsiteId, startDate, endDate, cohort },
options
) => {
const context = getAppContext(options);
Expand All @@ -57,7 +59,7 @@ export function createFunnelTools() {
return await callRPCProcedure(
"funnels",
"getAnalytics",
{ funnelId, websiteId, startDate, endDate },
{ funnelId, websiteId, startDate, endDate, cohort },
context
);
} catch (error) {
Expand All @@ -80,7 +82,7 @@ export function createFunnelTools() {
"Distinct visitors entering the first funnel step and completing its ordered steps, grouped by referrer/source. Counts are visitors, not projects or attempts. Accepts one date range; compare periods with separate calls.",
inputSchema: funnelAnalyticsInputSchema,
execute: async (
{ funnelId, websiteId: inputWebsiteId, startDate, endDate },
{ funnelId, websiteId: inputWebsiteId, startDate, endDate, cohort },
options
) => {
const context = getAppContext(options);
Expand All @@ -89,7 +91,7 @@ export function createFunnelTools() {
return await callRPCProcedure(
"funnels",
"getAnalyticsByReferrer",
{ funnelId, websiteId, startDate, endDate },
{ funnelId, websiteId, startDate, endDate, cohort },
context
);
} catch (error) {
Expand Down
8 changes: 5 additions & 3 deletions packages/ai/src/ai/tools/goals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { analyticsCohortSchema } from "@databuddy/shared/analytics-filters";
import { tool } from "ai";
import { analyticsDateRangeSchema } from "@databuddy/validation";
import { z } from "zod";
Expand All @@ -19,6 +20,7 @@ const goalFilterSchema = z.object({
const goalAnalyticsInputSchema = analyticsDateRangeSchema.safeExtend({
goalId: z.string(),
websiteId: z.string().optional(),
cohort: analyticsCohortSchema.optional(),
});
const createGoalInputSchema = z.object({
websiteId: z.string(),
Expand Down Expand Up @@ -65,10 +67,10 @@ export function createGoalTools() {

const getGoalAnalyticsTool = tool({
description:
"Goal definition, measured dates and distinct visitor counts. total_users_entered: website page-view visitors matching filters except event_name. total_users_completed: visitors matching the goal. overall_conversion_rate: completed / entered percent, not login or attempt success. Reuse matching verified measurements; remeasure stale or conflicting context.",
"Goal definition, measured dates and distinct visitor counts. total_users_entered: website page-view visitors matching filters except event_name. total_users_completed: visitors matching the goal. overall_conversion_rate: completed / entered percent, not login or attempt success. Optional cohort measures browser, device, country or campaign segments without editing the saved definition. Compare cohorts and periods with parallel calls. Reuse matching verified measurements; remeasure stale or conflicting context.",
inputSchema: goalAnalyticsInputSchema,
execute: async (
{ goalId, websiteId: inputWebsiteId, startDate, endDate },
{ goalId, websiteId: inputWebsiteId, startDate, endDate, cohort },
options
) => {
const context = getAppContext(options);
Expand All @@ -77,7 +79,7 @@ export function createGoalTools() {
return await callRPCProcedure(
"goals",
"getAnalytics",
{ goalId, websiteId, startDate, endDate },
{ goalId, websiteId, startDate, endDate, cohort },
context
);
} catch (error) {
Expand Down
Loading
Loading