diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index f7d4215d7..58b3762d5 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -104,6 +104,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - For dashboard navigation audits, check all route surfaces: `components/layout/navigation/navigation-config.tsx`, `components/ui/command-search.tsx`, and local `PageNavigation` layouts under `app/**/layout.tsx` before calling a page orphaned. - When fixing broken dashboard links to moved sections, update the real docs/search/navigation links and section anchors directly; do not add compatibility redirect pages unless explicitly requested. - Custom events UI is shared in `apps/dashboard/components/events/custom-events`; keep many-series legends outside the Recharts plot, use compact controls for property-summary event selection, and avoid separate event-count chip/list sections. +- Goals and Funnels are sibling conversion surfaces; keep Goals list-first and visually aligned with `app/(main)/websites/[id]/funnels` instead of adding separate summary-card chrome. - Insights merged feed (`use-insights-feed`) collapses history + AI by `insightSignalDedupeKey` in `apps/dashboard/lib/insight-signal-key.ts` so the list is one row per signal (latest wins). - Insights page (`app/(main)/insights`) should stay focused on the brief + signal queue; do not add generic global analytics KPI cards or top pages/referrers/countries tables there. - Theme: `apps/dashboard/app/globals.css`. **`--border` is intentionally subtle**; do not crank it darker for “contrast” unless **iza** asks—prefer text tokens or layout for readability. diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-item.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-item.tsx index d01c99fb3..c08a8d95f 100644 --- a/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-item.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/goals/_components/goal-item.tsx @@ -7,9 +7,8 @@ import { formatNumber } from "@/lib/formatters"; import { cn } from "@/lib/utils"; import { DotsThreeIcon, - EyeIcon, - MouseMiddleClickIcon, PencilSimpleIcon, + TargetIcon, TrashIcon, WarningCircleIcon, } from "@databuddy/ui/icons"; @@ -23,30 +22,49 @@ interface GoalItemProps { onEdit: (goal: Goal) => void; } -const GOAL_TYPE_CONFIG = { - PAGE_VIEW: { - icon: EyeIcon, - bg: "bg-blue-500/10 text-blue-600 dark:text-blue-400", - }, - EVENT: { - icon: MouseMiddleClickIcon, - bg: "bg-violet-500/10 text-violet-600 dark:text-violet-400", - }, - CUSTOM: { - icon: MouseMiddleClickIcon, - bg: "bg-muted text-muted-foreground", - }, -} as const; - function GoalProgress({ rate }: { rate: number }) { const clampedRate = Math.max(0, Math.min(100, rate)); return ( -
+
-
+
+ ); +} + +function formatGoalType(type: Goal["type"]) { + if (type === "PAGE_VIEW") { + return "Page View"; + } + if (type === "EVENT") { + return "Event"; + } + return "Custom"; +} + +function GoalMetadata({ goal }: { goal: Goal }) { + return ( +

+ {formatGoalType(goal.type)} + + + {goal.target} + + {goal.description ? ( + <> + + + {goal.description} + + + ) : null} +

); } @@ -61,36 +79,25 @@ export function GoalItem({ const analyticsError = analytics && !analytics.ok ? analytics.error : null; const rate = analyticsData?.overall_conversion_rate ?? 0; const users = analyticsData?.total_users_completed ?? 0; - const eligibleUsers = analyticsData?.total_users_entered ?? 0; - const config = GOAL_TYPE_CONFIG[goal.type] ?? GOAL_TYPE_CONFIG.PAGE_VIEW; - const TypeIcon = config.icon; return ( - - -
- + + +
+
- -

- {goal.name} -

-
- -

- {goal.target} -

+
+

+ {goal.name} +

+ +
- + {isLoadingAnalytics ? ( <> @@ -115,13 +122,7 @@ export function GoalItem({ {formatNumber(users)} - Completed -
-
- - {formatNumber(eligibleUsers)} - - Eligible + Completions
@@ -133,7 +134,7 @@ export function GoalItem({ )} - + {isLoadingAnalytics ? ( ) : analyticsError ? ( @@ -148,7 +149,7 @@ export function GoalItem({ )} - + - +
+
- +
-
+
- - + +
); } diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx index 882f6465c..354cf21db 100644 --- a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx @@ -10,7 +10,6 @@ import { useDateFilters } from "@/hooks/use-date-filters"; import { type CreateGoalData, type Goal, - type GoalAnalyticsRecord, useBulkGoalAnalytics, useGoals, } from "@/hooks/use-goals"; @@ -20,16 +19,9 @@ import type { DynamicQueryFilter, GoalFilter } from "@/types/api"; import { EditGoalDialog } from "./_components/edit-goal-dialog"; import { GoalItemSkeleton } from "./_components/goal-item"; import { GoalsList } from "./_components/goals-list"; -import { - ArrowClockwiseIcon, - CheckCircleIcon, - PlusIcon, - TargetIcon, - TrendUpIcon, -} from "@databuddy/ui/icons"; -import { Button, Card } from "@databuddy/ui"; +import { ArrowClockwiseIcon, PlusIcon, TargetIcon } from "@databuddy/ui/icons"; +import { Button } from "@databuddy/ui"; import { DeleteDialog } from "@databuddy/ui/client"; -import { formatNumber } from "@/lib/formatters"; import { cn } from "@/lib/utils"; import { useAtomValue } from "jotai"; @@ -63,71 +55,6 @@ function toGoalFilters(filters: DynamicQueryFilter[]): GoalFilter[] { })); } -function GoalsSummary({ - analytics, - goals, - isLoading, -}: { - analytics?: GoalAnalyticsRecord; - goals: Goal[]; - isLoading: boolean; -}) { - const results = Object.values(analytics ?? {}).filter((result) => result.ok); - const completions = results.reduce( - (total, result) => total + result.data.total_users_completed, - 0 - ); - const averageConversion = - results.length > 0 - ? results.reduce( - (total, result) => total + result.data.overall_conversion_rate, - 0 - ) / results.length - : 0; - - const cards = [ - { - icon: TargetIcon, - label: "Active goals", - value: formatNumber(goals.filter((goal) => goal.isActive).length), - }, - { - icon: CheckCircleIcon, - label: "Completions", - value: formatNumber(completions), - }, - { - icon: TrendUpIcon, - label: "Avg. conversion", - value: `${averageConversion.toFixed(1)}%`, - }, - ]; - - return ( -
- {cards.map(({ icon: Icon, label, value }) => ( - - -
- -
-
-

{label}

- {isLoading ? ( -
- ) : ( -

- {value} -

- )} -
- - - ))} -
- ); -} - export default function GoalsPage() { const { id } = useParams(); const websiteId = id as string; @@ -242,53 +169,44 @@ export default function GoalsPage() {
-
- { - setEditingGoal(null); - setIsDialogOpen(true); - }, + { + setEditingGoal(null); + setIsDialogOpen(true); }, - description: - "Track single-step conversions like signups, purchases, or activation events.", - icon: , - title: "No goals yet", - }} - errorProps={{ - action: { label: "Retry", onClick: () => refreshAction() }, - description: - error?.message ?? - "Something went wrong while loading goal data.", - icon: , - title: "Failed to load goals", - }} - loading={} - outcome={listOutcome} - > - {(items) => ( - <> - - setDeletingGoalId(goalId)} - onEditGoal={(goal) => { - setEditingGoal(goal); - setIsDialogOpen(true); - }} - /> - - )} - -
+ }, + description: + "Track single-step conversions like signups, purchases, or activation events.", + icon: , + title: "No goals yet", + }} + errorProps={{ + action: { label: "Retry", onClick: () => refreshAction() }, + description: + error?.message ?? + "Something went wrong while loading goal data.", + icon: , + title: "Failed to load goals", + }} + loading={} + outcome={listOutcome} + > + {(items) => ( + setDeletingGoalId(goalId)} + onEditGoal={(goal) => { + setEditingGoal(goal); + setIsDialogOpen(true); + }} + /> + )} +
{isDialogOpen && ( diff --git a/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx b/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx index 5d76224a2..a69e9e8ea 100644 --- a/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/settings/general/page.tsx @@ -7,16 +7,21 @@ import { toast } from "sonner"; import { NoticeBanner } from "@/app/(main)/websites/_components/notice-banner"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { + getWebsiteByIdKey, + getWebsitesListKey, updateWebsiteCache, useDeleteWebsite, useUpdateWebsite, useWebsite, type Website, + type WebsitesListData, } from "@/hooks/use-websites"; import { orpc } from "@/lib/orpc"; +import { publicConfig } from "@databuddy/env/public"; import { TOAST_MESSAGES } from "../../_components/constants/settings-constants"; import { ArrowRightIcon, + ArrowSquareOutIcon, CheckIcon, ClipboardIcon, GlobeIcon, @@ -71,6 +76,45 @@ export default function GeneralSettingsPage() { const toggleMutation = useMutation({ ...orpc.websites.togglePublic.mutationOptions(), + onMutate: async ({ id, isPublic: nextIsPublic }) => { + const getByIdKey = getWebsiteByIdKey(id); + const listKey = getWebsitesListKey(); + + await Promise.all([ + queryClient.cancelQueries({ queryKey: getByIdKey }), + queryClient.cancelQueries({ queryKey: listKey }), + ]); + + const previousWebsite = queryClient.getQueryData(getByIdKey); + const previousList = queryClient.getQueryData(listKey); + const withPublicState = (website: Website): Website => ({ + ...website, + isPublic: nextIsPublic, + }); + + queryClient.setQueryData(getByIdKey, (current) => + current ? withPublicState(current) : current + ); + queryClient.setQueryData(listKey, (current) => + current + ? { + ...current, + websites: current.websites.map((website) => + website.id === id ? withPublicState(website) : website + ), + } + : current + ); + + return { getByIdKey, listKey, previousList, previousWebsite }; + }, + onError: (_error, _variables, context) => { + if (!context) { + return; + } + queryClient.setQueryData(context.getByIdKey, context.previousWebsite); + queryClient.setQueryData(context.listKey, context.previousList); + }, onSuccess: (updatedWebsite: Website) => { updateWebsiteCache(queryClient, updatedWebsite); queryClient.invalidateQueries({ @@ -95,13 +139,14 @@ export default function GeneralSettingsPage() { onCopy: () => toast.success("Public link copied to clipboard"), }); - const isPublic = websiteData?.isPublic ?? false; - const shareableLink = useMemo(() => { - if (!(websiteData && typeof window !== "undefined")) { - return ""; - } - return `${window.location.origin}/public/${websiteId}`; - }, [websiteData, websiteId]); + const isPublic = + (toggleMutation.isPending + ? toggleMutation.variables?.isPublic + : websiteData?.isPublic) ?? false; + const shareableLink = useMemo( + () => `${publicConfig.urls.dashboard}/public/${websiteId}`, + [websiteId] + ); const hasChanges = !!websiteData && @@ -173,6 +218,10 @@ export default function GeneralSettingsPage() { [websiteData, websiteId, toggleMutation] ); + const handleOpenPublicPage = useCallback(() => { + window.open(shareableLink, "_blank", "noopener,noreferrer"); + }, [shareableLink]); + if (!websiteData) { return ; } @@ -271,11 +320,11 @@ export default function GeneralSettingsPage() {
- {shareableLink || `/public/${websiteId}`} + {shareableLink} +
, index: number) { + return blocks[index]?.text?.text ?? ""; +} + +describe("Slack insight digest markdown", () => { + it("uses the website name with domain in the header", () => { + const blocks = buildBlocks( + "Databuddy", + "app.databuddy.cc", + [ + { + actions: [{ label: "Switch goal to /billing contains" }], + description: + "The goal only matches /billing, but 15 of 32 billing visitors landed on /billing/plans or /billing/history.", + id: "insight-1", + impactSummary: + "Billing interest is stronger than the goal reports.", + severity: "warning", + sentiment: "negative", + suggestion: + "Edit the goal id 019d7dac-6c23-7000-b8b0-b5cacc81db79.", + title: "Pricing intent is undercounted by about 47%", + type: "conversion_leak", + }, + ], + [] + ); + + expect(blocks[0]?.text?.text).toBe( + "Insights for Databuddy (app.databuddy.cc)" + ); + expect(buildFallbackText("Databuddy <@U123>", "app.databuddy.cc")).toBe( + "Insights for Databuddy <@U123> (app.databuddy.cc)" + ); + }); + + it("renders each card as label, title, evidence, impact, and next action", () => { + const blocks = buildBlocks( + "Databuddy", + "app.databuddy.cc", + [ + { + actions: [{ label: "Switch goal to /billing contains" }], + description: + "The goal only matches /billing, but 15 of 32 billing visitors landed on /billing/plans or /billing/history.", + id: "goal-insight", + impactSummary: + " Billing interest is stronger than the goal reports. ", + severity: "warning", + sentiment: "negative", + suggestion: + "Edit the Pricing viewers goal and include nested billing routes.", + title: "Pricing intent is undercounted by about 47%", + type: "conversion_leak", + }, + { + actions: [{ label: "Fix clipboard copy on /onboarding" }], + description: + "A single /onboarding session produced 94% of this week's errors after Firefox blocked clipboard access.", + id: "error-insight", + impactSummary: + "The error feed is being distorted by one retry loop.", + severity: "warning", + sentiment: "negative", + suggestion: + "Wrap navigator.clipboard.writeText in a try/catch with document.execCommand('copy').", + title: "One Firefox session caused 168 clipboard errors", + type: "persistent_error_hotspot", + }, + ], + [] + ); + + expect(blocks).toHaveLength(3); + expect(sectionText(blocks, 1)).toContain("*Fix · Goal tracking*"); + expect(sectionText(blocks, 1)).toContain( + "*Pricing intent is undercounted by about 47%*" + ); + expect(sectionText(blocks, 1)).toContain("Evidence: The goal only matches"); + expect(sectionText(blocks, 1)).toContain( + "Why it matters: Billing interest is stronger" + ); + expect(sectionText(blocks, 1)).not.toContain( + "Why it matters: Billing" + ); + expect(sectionText(blocks, 1)).toContain( + "Next: Switch goal to /billing contains" + ); + expect(sectionText(blocks, 2)).toContain("*Fix · Error volume*"); + expect(sectionText(blocks, 2)).toContain( + "Next: Fix clipboard copy on /onboarding" + ); + expect(sectionText(blocks, 1)).not.toContain("One Firefox"); + }); + + it("does not expose raw IDs or code-heavy suggestions in visible Slack copy", () => { + const blocks = buildBlocks( + "Databuddy", + "app.databuddy.cc", + [ + { + actions: [], + description: + "Two funnels share ids 019d7dac-6c23-7000-b8b0-b5cacc81db79 and 019d7dac-ef9b-... but have identical results.", + id: "duplicate-funnel", + severity: "warning", + sentiment: "negative", + suggestion: + "Delete funnel 019d7dac-6c23-7000-b8b0-b5cacc81db79 and run document.execCommand('copy') in the console.", + title: + "Duplicate funnel 019d7dac-6c23-7000-b8b0-b5cacc81db79 is active", + type: "funnel_regression", + }, + ], + [] + ); + + const text = sectionText(blocks, 1); + expect(text).toContain("*Cleanup · Funnel config*"); + expect(text).toContain("*Duplicate funnel the affected item is active*"); + expect(text).toContain("Evidence: Two funnels share ids the affected item"); + expect(text).toContain( + "Next: Review the funnel configuration and remove duplicate setup if present." + ); + expect(text).not.toContain("019d7dac"); + expect(text).not.toContain("document.execCommand"); + expect(text).not.toContain("Delete funnel"); + }); + + it("falls back to the domain when no website name exists", () => { + const blocks = buildBlocks( + null, + "example.com", + [ + { + description: "Traffic rose from 10 to 30 sessions.", + id: "traffic-insight", + severity: "info", + sentiment: "positive", + suggestion: "Annotate the campaign.", + title: "Traffic tripled this week", + type: "positive_trend", + }, + ], + [] + ); + + expect(blocks[0]?.text?.text).toBe("Insights for example.com"); + expect(sectionText(blocks, 1)).toContain("*Opportunity · Acquisition*"); + expect(sectionText(blocks, 1)).toContain("Next: Annotate the campaign."); + }); +}); diff --git a/apps/insights/src/delivery.ts b/apps/insights/src/delivery.ts index 2019a8e09..0efa14040 100644 --- a/apps/insights/src/delivery.ts +++ b/apps/insights/src/delivery.ts @@ -22,9 +22,12 @@ interface DigestInsight { actions?: { label: string }[] | null; description: string; id: string; + impactSummary?: string | null; + sentiment: string; severity: string; suggestion: string; title: string; + type: string; } interface SlackBlock { @@ -39,6 +42,42 @@ function escapeMrkdwn(value: string): string { .replaceAll(">", ">"); } +const FULL_UUID_PATTERN = + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi; +const IMPLEMENTATION_DETAIL_MARKERS = [ + "document.", + "navigator.", + "execcommand", + "writetext", + "try/catch", +] as const; +const TRUNCATED_UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-\.\.\./gi; + +function userVisibleCopy(value: string): string { + return value + .replace(FULL_UUID_PATTERN, "the affected item") + .replace(TRUNCATED_UUID_PATTERN, "the affected item"); +} + +function formatWebsiteLabel( + websiteName: string | null | undefined, + websiteDomain: string +): string { + const name = websiteName?.trim(); + return name && name !== websiteDomain + ? `${name} (${websiteDomain})` + : websiteDomain; +} + +export function buildFallbackText( + websiteName: string | null | undefined, + websiteDomain: string +): string { + return escapeMrkdwn( + `Insights for ${formatWebsiteLabel(websiteName, websiteDomain)}` + ); +} + async function resolveDeliveries( organizationId: string, websiteId: string @@ -96,32 +135,138 @@ function chainSiteCount(insightId: string, chains: ChainAssignment[]): number { return chain ? new Set(chain.websiteIds).size : 0; } -function buildBlocks( +function digestLabel(insight: DigestInsight): string { + switch (insight.type) { + case "referrer_change": + case "traffic_spike": + case "positive_trend": + return insight.sentiment === "positive" + ? "Opportunity · Acquisition" + : "Review · Traffic"; + case "conversion_leak": + return "Fix · Goal tracking"; + case "funnel_regression": + return "Cleanup · Funnel config"; + case "error_spike": + case "new_errors": + case "persistent_error_hotspot": + case "error_impact": + return "Fix · Error volume"; + case "vitals_degraded": + case "performance": + return "Fix · Performance"; + case "performance_improved": + case "reliability_improved": + return "Improvement · Reliability"; + case "quality_shift": + case "segment_regression": + return "Review · Data quality"; + default: + return insight.severity === "info" + ? "Review · Signal" + : "Fix · Priority signal"; + } +} + +function fallbackWhyItMatters(insight: DigestInsight): string { + switch (insight.type) { + case "referrer_change": + case "traffic_spike": + case "positive_trend": + return "This is a channel or segment worth repeating while the context is fresh."; + case "conversion_leak": + return "Conversion analysis starts from bad data until this tracking is fixed."; + case "funnel_regression": + return "Funnel reports can double-count or hide the real drop-off until this is cleaned up."; + case "error_spike": + case "new_errors": + case "persistent_error_hotspot": + case "error_impact": + return "This can distort error reporting and may block affected users."; + case "vitals_degraded": + case "performance": + return "Slow or unstable pages can make the affected flow feel unreliable."; + default: + return "This changes which follow-up should happen next."; + } +} + +function fallbackNextAction(insight: DigestInsight): string { + switch (insight.type) { + case "referrer_change": + case "traffic_spike": + case "positive_trend": + return "Add an annotation so future weeks have context."; + case "conversion_leak": + return "Fix the goal or funnel configuration."; + case "funnel_regression": + return "Review the funnel configuration and remove duplicate setup if present."; + case "error_spike": + case "new_errors": + case "persistent_error_hotspot": + case "error_impact": + return "Create a fix task for the affected flow."; + case "vitals_degraded": + case "performance": + return "Profile the affected route and fix the slowest step."; + default: + return "Review this insight in Databuddy."; + } +} + +function visibleSuggestion(value: string): string | null { + const copy = userVisibleCopy(value).trim(); + if (!copy) { + return null; + } + + const lowerCopy = copy.toLowerCase(); + if ( + IMPLEMENTATION_DETAIL_MARKERS.some((marker) => lowerCopy.includes(marker)) + ) { + return null; + } + + return copy; +} + +function nextAction(insight: DigestInsight): string { + const label = (insight.actions ?? []) + .map((action) => action.label.trim()) + .find(Boolean); + return ( + label ?? + visibleSuggestion(insight.suggestion) ?? + fallbackNextAction(insight) + ); +} + +export function buildBlocks( + websiteName: string | null | undefined, websiteDomain: string, insights: DigestInsight[], chains: ChainAssignment[] ): SlackBlock[] { + const websiteLabel = formatWebsiteLabel(websiteName, websiteDomain); const blocks: SlackBlock[] = [ { type: "header", text: { type: "plain_text", - text: truncate(`Insights for ${websiteDomain}`, SLACK_HEADER_MAX), + text: truncate(`Insights for ${websiteLabel}`, SLACK_HEADER_MAX), }, }, ]; for (const insight of insights.slice(0, MAX_DIGEST_INSIGHTS)) { + const whyItMatters = + insight.impactSummary?.trim() || fallbackWhyItMatters(insight); const lines = [ - `*${escapeMrkdwn(insight.title)}*`, - escapeMrkdwn(insight.description), - `_${escapeMrkdwn(insight.suggestion)}_`, + `*${escapeMrkdwn(digestLabel(insight))}*`, + `*${escapeMrkdwn(userVisibleCopy(insight.title))}*`, + `Evidence: ${escapeMrkdwn(userVisibleCopy(insight.description))}`, + `Why it matters: ${escapeMrkdwn(userVisibleCopy(whyItMatters))}`, + `Next: ${escapeMrkdwn(userVisibleCopy(nextAction(insight)))}`, ]; - const actionLabels = (insight.actions ?? []) - .map((action) => action.label) - .filter(Boolean); - if (actionLabels.length > 0) { - lines.push(`Next: ${actionLabels.map(escapeMrkdwn).join(" · ")}`); - } const siteCount = chainSiteCount(insight.id, chains); if (siteCount > 1) { lines.push( @@ -167,6 +312,7 @@ export async function deliverInsightDigests(params: { organizationId: string; websiteDomain: string; websiteId: string; + websiteName?: string | null; }): Promise { if (params.insights.length === 0) { return; @@ -196,11 +342,12 @@ export async function deliverInsightDigests(params: { } const blocks = buildBlocks( + params.websiteName, params.websiteDomain, params.insights, params.chains ?? [] ); - const text = `Insights for ${params.websiteDomain}`; + const text = buildFallbackText(params.websiteName, params.websiteDomain); for (const channelId of slackChannelIds) { try { await postToSlack(token, channelId, blocks, text); diff --git a/apps/insights/src/generation.ts b/apps/insights/src/generation.ts index ebd22220b..14fd0f03f 100644 --- a/apps/insights/src/generation.ts +++ b/apps/insights/src/generation.ts @@ -631,6 +631,7 @@ export async function generateWebsiteInsights( organizationId: input.organizationId, websiteId: site.id, websiteDomain: site.domain, + websiteName: site.name, insights: freshInsights, chains: chainAssignments, }); diff --git a/apps/insights/src/prompts.ts b/apps/insights/src/prompts.ts index 4379349c4..8357ad33b 100644 --- a/apps/insights/src/prompts.ts +++ b/apps/insights/src/prompts.ts @@ -415,10 +415,11 @@ export function buildSystemPrompt( RULES: - Titles: outcome-first, plain language, ≤80 chars. No hedging, no jargon (INP, LCP, TTFB, CLS, p75). +- Titles should sound like a calm analyst, not an alert siren. Avoid "still", "again", "broken", and "not fixed" unless recurrence is the central evidence. - Title direction MUST match the primary metric. Mismatches are rejected. - Only report signals that change what someone does today. Silence > noise. -- Suggestions: name the exact page, button, or query. Never say "monitor" or "watch". -- ZERO REPETITION: title = what. description = so what (≤300 chars). rootCause = why. evidence = new facts only. suggestion = one action (≤300 chars). +- Suggestions: name the exact page, button, or query. Never say "monitor" or "watch". Keep the visible suggestion human-readable; put raw object IDs only in action params. +- ZERO REPETITION: title = what. description = evidence (≤300 chars). impactSummary = why it matters. rootCause = why. evidence = new facts only. suggestion = one action (≤300 chars). - Metrics: only verified numbers. Label segment-specific values clearly. - Low traffic (<50 sessions/week): no percentage claims on <10 absolute values. - Tools: batch queries in web_metrics (up to 8). search_console for keywords. summary_metrics for headline numbers. diff --git a/packages/ai/src/ai/schemas/smart-insights-output.ts b/packages/ai/src/ai/schemas/smart-insights-output.ts index a9a293720..d2772fec8 100644 --- a/packages/ai/src/ai/schemas/smart-insights-output.ts +++ b/packages/ai/src/ai/schemas/smart-insights-output.ts @@ -17,17 +17,17 @@ export const insightSchema = z.object({ title: z .string() .describe( - "Brief plain-English headline under 80 chars for a founder/operator. Avoid raw metric jargon like INP, LCP, FCP, TTFB, CLS, p75 in titles; translate to outcomes such as 'Interactions got slower' or 'Pages feel slower'. Never paste opaque URL slugs." + "Brief plain-English headline under 80 chars for a founder/operator. Avoid raw metric jargon like INP, LCP, FCP, TTFB, CLS, p75 in titles; translate to outcomes such as 'Interactions got slower' or 'Pages feel slower'. Never paste opaque IDs or URL slugs. Use calm recurrence wording; avoid 'again'/'still' unless recurrence is the main finding." ), description: z .string() .describe( - "1-2 sentences: what changed and why it matters. Do NOT restate numbers from the title or metrics array. Add NEW context only. Under 300 characters." + "1-2 sentences: evidence for what changed. Do NOT restate numbers from the title or metrics array unless they are essential. Add NEW context only. Under 300 characters. Use object names, not raw IDs." ), suggestion: z .string() .describe( - "One specific action. Name the exact page, button, query, or tool to use. Under 300 characters." + "One specific action in plain English. Name the exact page, button, query, or tool to use. Under 300 characters. Do not expose raw internal IDs; put IDs only in action params." ), metrics: z .array(insightMetricSchema) @@ -106,7 +106,7 @@ export const insightSchema = z.object({ .string() .optional() .describe( - "Optional short statement of user or business impact. Use when the impact is clear from the available data. Keep to a single sentence." + "Optional short statement of why this matters to the operator. Use when the impact is clear from the available data. Keep to a single sentence." ), rootCause: z .string() diff --git a/packages/ai/src/ai/tools/utils/query.ts b/packages/ai/src/ai/tools/utils/query.ts index eea9b3f3d..d892f61c7 100644 --- a/packages/ai/src/ai/tools/utils/query.ts +++ b/packages/ai/src/ai/tools/utils/query.ts @@ -103,7 +103,11 @@ export async function executeTimedQuery>( } catch (error) { const executionTime = Date.now() - queryStart; - logger.error("Query failed", { + // Log at WARN, not ERROR: the error is re-thrown so the caller decides + // severity. Fatal callers (job-level) will log ERROR when it propagates; + // recoverable callers (e.g. multi-query sqlTool) catch and continue, and + // should not escalate the job-wide log to ERROR. + logger.warn("Query failed", { ...logContext, executionTime: `${executionTime}ms`, error: error instanceof Error ? error.message : "Unknown error",