-
Notifications
You must be signed in to change notification settings - Fork 175
Release: staging → main #403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e87c23
a05319a
18d57c6
40e7161
dfc5810
51506eb
60eca9e
32655dd
3ad187d
9f04009
78f9243
90c3e86
22d3cba
cff89d3
dfef6e9
793d649
ef0eb21
a6fc1c2
e11d892
7a70091
1ff5ab2
db2ee0b
d9f8981
19ebd96
20e56ac
783f812
37c1fb4
f13d494
594f922
13f0ac3
c4f0de9
f75b145
7bae1db
6c353fd
c167832
1a1f707
7c1c74c
8e243d7
b72e2d0
8711dea
7d06f4f
5aeaa67
a9b3b02
f9626e6
3f8d344
ec1a29b
b5bf648
6a3bd8a
3c46b8b
a1f3f46
f6b12d3
b95f04b
dd5937f
65ba3db
e86d436
2951d75
50a080a
2758618
33fad25
88670bb
dc0f306
e8aed06
70ddaff
7fc4e58
5197ee8
01c4b66
71552bb
9006b84
39c0579
28eadac
690693f
70a21e3
a169bde
a98735a
658a23e
f6dfcba
00cbfb3
ca5ba1a
3560b46
9723f92
b67c5c5
fff568c
829a913
0c72064
582037f
2dd904f
2739413
9710496
f472c6e
5f2aca2
7d19db1
805f652
1fcce56
92ebfb7
931b5c5
f363f36
cbc4387
d79d831
861dbc7
971c8ef
40dddd3
7941705
cb4dca2
c64e92a
8d014a3
9f4e70b
3a715e2
902f6d0
95f3ec1
d7fda93
14c5ca6
67e13fb
9630653
5c6c49a
4b2fbb3
2672dcc
698b1ab
265f7d0
7a43fff
d1b4429
1ea870a
d7765e2
45e3d7e
9258739
b1daa82
90263f4
4b7c986
9b93745
918d196
8c79a34
cd59952
cb478f5
52a63cc
16cbe06
079b5a3
e62fded
04ccdc1
d9fec85
68a4636
bbf47a5
3232d33
ddfb783
fab0b5f
3975967
6d22958
90fdd0c
126cf5c
c4dc2be
ec6f9b3
bf500ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,3 +65,4 @@ tmp | |
| # next-agents-md | ||
| .next-docs/ | ||
| .gstack/ | ||
| .superpowers/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { getAutumn } from "@databuddy/rpc"; | ||
| import { generateText, tool } from "ai"; | ||
| import { z } from "zod"; | ||
| import type { AppContext } from "../config/context"; | ||
|
|
@@ -67,6 +68,23 @@ export const webSearchTool = tool({ | |
| responseLength: result.text.length, | ||
| }); | ||
|
|
||
| if (appContext?.billingCustomerId) { | ||
| getAutumn() | ||
| .track({ | ||
| customerId: appContext.billingCustomerId, | ||
| featureId: "agent_web_search_calls", | ||
| value: 1, | ||
| }) | ||
| .catch((trackError) => { | ||
| logger.error("Failed to track web search usage", { | ||
| error: | ||
| trackError instanceof Error | ||
| ? trackError.message | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Suggestion: Fire-and-forget billing could silently under-count usage. If the Autumn billing service has a prolonged outage, web searches continue unmetered since |
||
| : String(trackError), | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Sanitize web content before returning to the agent to prevent | ||
| // indirect prompt injection from adversarial web pages. | ||
| const sanitized = result.text.replace( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,8 @@ import { | |
| member, | ||
| websites, | ||
| } from "@databuddy/db"; | ||
| import { getRedisCache } from "@databuddy/redis"; | ||
| import { cacheable, getRedisCache } from "@databuddy/redis"; | ||
| import { getRateLimitHeaders, rateLimit } from "@databuddy/redis/rate-limit"; | ||
| import { generateText, Output, stepCountIs, ToolLoopAgent } from "ai"; | ||
| import dayjs from "dayjs"; | ||
| import { Elysia, t } from "elysia"; | ||
|
|
@@ -23,7 +24,7 @@ import type { ParsedInsight } from "../ai/schemas/smart-insights-output"; | |
| import { insightsOutputSchema } from "../ai/schemas/smart-insights-output"; | ||
| import { createInsightsAgentTools } from "../ai/tools/insights-agent-tools"; | ||
| import { storeAnalyticsSummary } from "../lib/supermemory"; | ||
| import { mergeWideEvent } from "../lib/tracing"; | ||
| import { captureError, mergeWideEvent } from "../lib/tracing"; | ||
| import { executeQuery } from "../query"; | ||
|
|
||
| const CACHE_TTL = 900; | ||
|
|
@@ -927,6 +928,118 @@ async function invalidateInsightsCacheForOrg( | |
| } | ||
| } | ||
|
|
||
| const NARRATIVE_RATE_LIMIT = 30; | ||
| const NARRATIVE_RATE_WINDOW_SECS = 3600; | ||
| const NARRATIVE_CACHE_TTL_SECS = 3600; | ||
| const NARRATIVE_INSIGHTS_LIMIT = 5; | ||
|
|
||
| function rangeWord(range: "7d" | "30d" | "90d"): string { | ||
| if (range === "7d") { | ||
| return "week"; | ||
| } | ||
| if (range === "30d") { | ||
| return "month"; | ||
| } | ||
| return "quarter"; | ||
| } | ||
|
|
||
| function buildDeterministicNarrative( | ||
| range: "7d" | "30d" | "90d", | ||
| topInsights: { | ||
| title: string; | ||
| severity: string; | ||
| websiteName: string | null; | ||
| }[] | ||
| ): string { | ||
| const word = rangeWord(range); | ||
| if (topInsights.length === 0) { | ||
| return `All systems healthy this ${word}. No actionable signals detected.`; | ||
| } | ||
| const headline = topInsights[0]; | ||
| const siteSuffix = headline.websiteName ? ` on ${headline.websiteName}` : ""; | ||
| if (topInsights.length === 1) { | ||
| return `This ${word}: ${headline.title}${siteSuffix}.`; | ||
| } | ||
| const extra = topInsights.length - 1; | ||
| return `This ${word}: ${headline.title}${siteSuffix}, plus ${extra} more signal${extra === 1 ? "" : "s"} worth reviewing.`; | ||
| } | ||
|
|
||
| const generateNarrativeCached = cacheable( | ||
| async function generateNarrativeCached( | ||
| organizationId: string, | ||
| range: "7d" | "30d" | "90d" | ||
| ): Promise<{ narrative: string }> { | ||
| const topInsights = await db | ||
| .select({ | ||
| title: analyticsInsights.title, | ||
| description: analyticsInsights.description, | ||
| severity: analyticsInsights.severity, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Warning: DB query doesn't filter by
Consider adding a const cutoff = dayjs().subtract(
range === "7d" ? 7 : range === "30d" ? 30 : 90,
"day"
).toDate();
// ...
.where(
and(
eq(analyticsInsights.organizationId, organizationId),
gte(analyticsInsights.createdAt, cutoff)
)
) |
||
| changePercent: analyticsInsights.changePercent, | ||
| websiteName: websites.name, | ||
| }) | ||
|
Comment on lines
+966
to
+979
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
// before the `.limit(...)` call, compute and apply a cutoff:
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const cutoff = dayjs().subtract(days, "day").toDate();
.where(
and(
eq(analyticsInsights.organizationId, organizationId),
gte(analyticsInsights.createdAt, cutoff),
)
) |
||
| .from(analyticsInsights) | ||
| .innerJoin(websites, eq(analyticsInsights.websiteId, websites.id)) | ||
| .where(eq(analyticsInsights.organizationId, organizationId)) | ||
| .orderBy(desc(analyticsInsights.priority)) | ||
| .limit(NARRATIVE_INSIGHTS_LIMIT); | ||
|
|
||
| if (topInsights.length === 0) { | ||
| return { | ||
| narrative: `All systems healthy this ${rangeWord(range)}. No actionable signals detected.`, | ||
| }; | ||
| } | ||
|
|
||
| const insightLines = topInsights.map((ins) => { | ||
| const site = ins.websiteName ? ` [${ins.websiteName}]` : ""; | ||
| const change = | ||
| ins.changePercent == null | ||
| ? "" | ||
| : ` (${ins.changePercent > 0 ? "+" : ""}${ins.changePercent.toFixed(0)}%)`; | ||
| return `- [${ins.severity}] ${ins.title}${change}${site}: ${ins.description ?? ""}`; | ||
| }); | ||
|
|
||
| const prompt = `You are an analytics assistant summarizing an organization's state over the last ${range}. | ||
|
|
||
| Write a crisp 2–3 sentence executive summary of the top insights below. | ||
|
|
||
| Rules: | ||
| - Lead with the most important change | ||
| - Include concrete numbers when available | ||
| - Never exceed 60 words total | ||
| - State facts, do not editorialize | ||
| - If nothing meaningful is happening, say so plainly | ||
|
|
||
| Top signals this ${range}: | ||
| ${insightLines.join("\n")}`; | ||
|
|
||
| let narrative = ""; | ||
| try { | ||
| const result = await generateText({ | ||
| model: models.analytics, | ||
| prompt, | ||
| temperature: 0.2, | ||
| maxOutputTokens: 200, | ||
| }); | ||
| narrative = result.text.trim(); | ||
| } catch (error) { | ||
| useLogger().warn("Narrative LLM call failed", { | ||
| insights: { organizationId, range, error }, | ||
| }); | ||
| } | ||
|
|
||
| if (!narrative) { | ||
| narrative = buildDeterministicNarrative(range, topInsights); | ||
| mergeWideEvent({ insights_narrative_fallback: true }); | ||
| } | ||
|
|
||
| return { narrative }; | ||
| }, | ||
| { | ||
| expireInSec: NARRATIVE_CACHE_TTL_SECS, | ||
| prefix: "insights-narrative", | ||
| } | ||
| ); | ||
|
|
||
| export const insights = new Elysia({ prefix: "/v1/insights" }) | ||
| .derive(async ({ request }) => { | ||
| const session = await auth.api.getSession({ headers: request.headers }); | ||
|
|
@@ -1059,6 +1172,78 @@ export const insights = new Elysia({ prefix: "/v1/insights" }) | |
| }), | ||
| } | ||
| ) | ||
| .get( | ||
| "/org-narrative", | ||
| async ({ query, user, set }) => { | ||
| const userId = user?.id; | ||
| if (!userId) { | ||
| return { success: false, error: "User ID required" }; | ||
| } | ||
|
|
||
| const { organizationId, range } = query; | ||
| mergeWideEvent({ | ||
| insights_narrative_org_id: organizationId, | ||
| insights_narrative_range: range, | ||
| }); | ||
|
|
||
| const memberships = await db.query.member.findMany({ | ||
| where: eq(member.userId, userId), | ||
| columns: { organizationId: true }, | ||
| }); | ||
|
|
||
| const orgIds = new Set(memberships.map((m) => m.organizationId)); | ||
| if (!orgIds.has(organizationId)) { | ||
| mergeWideEvent({ insights_narrative_access: "denied" }); | ||
| set.status = 403; | ||
| return { success: false, error: "Access denied to this organization" }; | ||
| } | ||
|
|
||
| const rl = await rateLimit( | ||
| `insights:narrative:${organizationId}:${userId}`, | ||
| NARRATIVE_RATE_LIMIT, | ||
| NARRATIVE_RATE_WINDOW_SECS | ||
| ); | ||
| const rlHeaders = getRateLimitHeaders(rl); | ||
| for (const [key, value] of Object.entries(rlHeaders)) { | ||
| set.headers[key] = value; | ||
| } | ||
| if (!rl.success) { | ||
| set.status = 429; | ||
| return { | ||
| success: false, | ||
| error: "Rate limit exceeded. Try again later.", | ||
| }; | ||
| } | ||
|
|
||
| try { | ||
| const { narrative } = await generateNarrativeCached( | ||
| organizationId, | ||
| range | ||
| ); | ||
| return { | ||
| success: true, | ||
| narrative, | ||
| generatedAt: new Date().toISOString(), | ||
| }; | ||
| } catch (error) { | ||
| captureError(error, { | ||
| insights_narrative_org_id: organizationId, | ||
| insights_narrative_range: range, | ||
| }); | ||
| useLogger().warn("Failed to generate org narrative", { | ||
| insights: { organizationId, range, error }, | ||
| }); | ||
| set.status = 500; | ||
| return { success: false, error: "Could not generate narrative" }; | ||
| } | ||
| }, | ||
| { | ||
| query: t.Object({ | ||
| organizationId: t.String(), | ||
| range: t.Union([t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]), | ||
| }), | ||
| } | ||
| ) | ||
| .post( | ||
| "/clear", | ||
| async ({ body, user, set }) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Warning:
CURRENT_SCHEMAcomment referencingautumn.config.tswas removed.The old code had a helpful comment (
// Matches creditSchema in apps/dashboard/autumn.config.ts) that tied this probe script to the production pricing config. With the 20% markup applied here, it'd be worth keeping a similar breadcrumb so future edits stay in sync. If someone updatesautumn.config.tslater but forgets this file (or vice versa), the probe results will be wrong.