| import type { AppContext } from "@api/ai/agents/config/shared"; |
| import { db } from "@midday/db/client"; |
| import { |
| getInsightByPeriod, |
| getLatestInsight, |
| hasEarlierInsight, |
| type Insight, |
| } from "@midday/db/queries"; |
| import { getPeriodLabel } from "@midday/insights"; |
| import { formatAmount } from "@midday/utils/format"; |
| import { tool } from "ai"; |
| import { getISOWeek, getMonth, getQuarter, getYear } from "date-fns"; |
| import { z } from "zod"; |
|
|
| const getInsightsSchema = z.object({ |
| periodType: z |
| .enum(["weekly", "monthly", "quarterly", "yearly"]) |
| .default("weekly") |
| .describe("Type of insight period"), |
| periodNumber: z |
| .number() |
| .optional() |
| .describe("Period number (week 1-53, month 1-12, quarter 1-4)"), |
| year: z.number().optional().describe("Year for the insight period"), |
| }); |
|
|
| export const getInsightsTool = tool({ |
| description: |
| "Get AI-generated business insights summary for a period (weekly, monthly, quarterly, or yearly). Shows key metrics with comparisons, achievements, and personalized recommendations.", |
| inputSchema: getInsightsSchema, |
| execute: async function* ( |
| { periodType, periodNumber, year }, |
| executionOptions, |
| ) { |
| const appContext = executionOptions.experimental_context as AppContext; |
| const teamId = appContext.teamId as string; |
|
|
| if (!teamId) { |
| yield { |
| text: "Unable to retrieve insights: Team ID not found.", |
| }; |
| return { success: false }; |
| } |
|
|
| try { |
| let insight: Insight | null = null; |
|
|
| |
| |
| if (periodNumber !== undefined && year !== undefined) { |
| insight = await getInsightByPeriod(db, { |
| teamId, |
| periodType, |
| periodYear: year, |
| periodNumber, |
| }); |
| } else { |
| |
| insight = await getLatestInsight(db, { |
| teamId, |
| periodType, |
| }); |
| } |
|
|
| if (!insight || insight.status !== "completed") { |
| yield { |
| text: `No ${periodType} insights available yet. Insights are generated automatically and will appear here once ready.`, |
| }; |
| return { success: false, reason: "not_found" }; |
| } |
|
|
| const locale = appContext.locale || "en-US"; |
| const currency = insight.currency || appContext.baseCurrency || "USD"; |
|
|
| |
| let responseText = ""; |
|
|
| |
| const periodLabel = getPeriodLabel( |
| insight.periodType, |
| insight.periodYear, |
| insight.periodNumber, |
| ); |
|
|
| |
| if (insight.title) { |
| responseText += `**${periodLabel}**\n\n`; |
| responseText += `${insight.title}\n\n`; |
| } else { |
| responseText += `## ${periodLabel}\n\n`; |
| } |
|
|
| |
| if (insight.content?.story) { |
| responseText += `${insight.content.story}\n\n`; |
| } |
|
|
| |
| if (insight.content?.actions && insight.content.actions.length > 0) { |
| responseText += "**What to do:**\n"; |
| for (const action of insight.content.actions) { |
| responseText += `- ${action.text}\n`; |
| } |
| responseText += "\n"; |
| } |
|
|
| |
| if ( |
| insight.activity?.invoicesOverdue && |
| insight.activity.invoicesOverdue > 0 |
| ) { |
| responseText += "**Needs attention:**\n"; |
| responseText += `- ${insight.activity.invoicesOverdue} overdue invoice${insight.activity.invoicesOverdue > 1 ? "s" : ""}`; |
| if (insight.activity.overdueAmount) { |
| responseText += ` (${formatMetricValue(insight.activity.overdueAmount, "currency", currency, locale)})`; |
| } |
| responseText += "\n\n"; |
| } |
|
|
| |
| if (insight.selectedMetrics && insight.selectedMetrics.length > 0) { |
| responseText += "**The numbers:**\n"; |
| for (const metric of insight.selectedMetrics.slice(0, 4)) { |
| const formattedValue = formatMetricValue( |
| metric.value, |
| metric.type, |
| currency, |
| locale, |
| ); |
| const changeText = formatChangeCompact( |
| metric.change, |
| metric.changeDirection, |
| ); |
| responseText += `- ${metric.label}: ${formattedValue} ${changeText}\n`; |
| } |
| responseText += "\n"; |
| } |
|
|
| |
| if (insight.expenseAnomalies && insight.expenseAnomalies.length > 0) { |
| const spikes = insight.expenseAnomalies.filter( |
| (ea) => ea.type === "category_spike" || ea.type === "new_category", |
| ); |
| if (spikes.length > 0) { |
| responseText += "**Expense heads up:**\n"; |
| for (const ea of spikes.slice(0, 3)) { |
| const currentFormatted = formatMetricValue( |
| ea.currentAmount, |
| "currency", |
| currency, |
| locale, |
| ); |
| if (ea.type === "new_category") { |
| responseText += `- New: ${ea.categoryName} (${currentFormatted})\n`; |
| } else { |
| responseText += `- ${ea.categoryName} up ${ea.change}% to ${currentFormatted}\n`; |
| } |
| } |
| responseText += "\n"; |
| } |
| } |
|
|
| |
| |
| const isFirstInsight = !(await hasEarlierInsight(db, { |
| teamId, |
| periodType: insight.periodType, |
| periodYear: insight.periodYear, |
| periodNumber: insight.periodNumber, |
| })); |
|
|
| |
| const insightData = { |
| id: insight.id, |
| periodLabel, |
| periodType: insight.periodType, |
| periodYear: insight.periodYear, |
| periodNumber: insight.periodNumber, |
| currency, |
| title: insight.title, |
| selectedMetrics: insight.selectedMetrics, |
| content: insight.content, |
| anomalies: insight.anomalies, |
| expenseAnomalies: insight.expenseAnomalies, |
| milestones: insight.milestones, |
| activity: insight.activity, |
| predictions: insight.predictions, |
| generatedAt: insight.generatedAt, |
| isFirstInsight, |
| }; |
|
|
| yield { |
| text: responseText, |
| success: true, |
| insight: insightData, |
| }; |
|
|
| |
| return { |
| success: true, |
| insight: insightData, |
| instruction: |
| "The insight has been displayed to the user. Do not repeat or summarize it.", |
| }; |
| } catch (error) { |
| yield { |
| text: `Failed to retrieve insights: ${error instanceof Error ? error.message : "Unknown error"}`, |
| }; |
| return { success: false, reason: "error" }; |
| } |
| }, |
| }); |
|
|
| function formatMetricValue( |
| value: number, |
| type: string, |
| currency: string, |
| locale: string, |
| ): string { |
| |
| if ( |
| type.includes("margin") || |
| type.includes("rate") || |
| type === "profit_margin" |
| ) { |
| return `${value.toFixed(1)}%`; |
| } |
|
|
| |
| if (type === "runway_months") { |
| return `${value.toFixed(1)} months`; |
| } |
|
|
| if ( |
| type === "hours_tracked" || |
| type === "billable_hours" || |
| type === "unbilled_hours" |
| ) { |
| return `${value.toFixed(1)}h`; |
| } |
|
|
| |
| if ( |
| type.includes("invoices") || |
| type.includes("customers") || |
| type === "new_customers" || |
| type === "active_customers" || |
| type === "receipts_matched" || |
| type === "transactions_categorized" |
| ) { |
| return value.toLocaleString(locale); |
| } |
|
|
| |
| return ( |
| formatAmount({ |
| amount: value, |
| currency: currency || "USD", |
| locale, |
| }) ?? value.toLocaleString(locale) |
| ); |
| } |
|
|
| function formatChangeCompact( |
| change: number, |
| direction: "up" | "down" | "flat", |
| ): string { |
| if (direction === "flat" || Math.abs(change) < 0.5) { |
| return "(steady)"; |
| } |
|
|
| const sign = direction === "up" ? "+" : "-"; |
| return `(${sign}${Math.abs(Math.round(change))}%)`; |
| } |
|
|
| |
| export function getCurrentPeriodInfo(periodType: string): { |
| year: number; |
| number: number; |
| } { |
| const now = new Date(); |
| const year = getYear(now); |
|
|
| switch (periodType) { |
| case "weekly": |
| return { year, number: getISOWeek(now) }; |
| case "monthly": |
| return { year, number: getMonth(now) + 1 }; |
| case "quarterly": |
| return { year, number: getQuarter(now) }; |
| case "yearly": |
| |
| return { year, number: year }; |
| default: |
| return { year, number: getISOWeek(now) }; |
| } |
| } |
|
|