docs/weekly-insights.md
The Weekly Insights system generates AI-powered summaries of business financial performance. It analyzes transactions, invoices, and historical patterns to create actionable weekly reports for SMB owners.
sequenceDiagram
participant Job as Weekly Job
participant Service as InsightsService
participant DB as Database
participant Calc as MetricsCalculator
participant Gen as ContentGenerator
participant AI as OpenAI
Job->>Service: generate(teamId, periodType, date)
Service->>DB: Fetch metrics, transactions, invoices
Service->>Calc: Calculate current & previous period
Calc->>Service: MetricData with changes
Service->>Gen: Generate content
Gen->>Gen: Compute slots (pre-processed data)
Gen->>AI: Title prompt
Gen->>AI: Summary prompt (parallel)
Gen->>AI: Actions prompt (parallel)
AI->>Gen: Generated content
Gen->>AI: Story prompt (with context)
AI->>Gen: Final story
Gen->>Service: InsightContent
Service->>DB: Store insight
Content is generated using focused, single-purpose prompts for maximum quality:
| Prompt | Purpose | Dependencies |
|---|---|---|
title.ts | 15-25 word headline | Slots only |
summary.ts | 40-60 word overview | Slots only |
actions.ts | 3-5 prioritized recommendations | Slots only |
story.ts | Highlight narrative | Title + Summary |
The computeSlots() function pre-processes all data before AI generation:
type InsightSlots = {
// Core financials
profit: string; // "117,061 kr"
profitRaw: number; // 117061
revenue: string;
expenses: string;
margin: string; // "97.4"
runway: number; // 8
runwayExhaustionDate?: string; // "March 3, 2026"
// Changes
profitChange: number; // 96
profitDirection: "up" | "down" | "flat";
profitChangeDescription: string; // Pre-computed semantic description
// Context
weekType: "great" | "good" | "quiet" | "challenging";
highlight: { type: string; description?: string };
streak?: { type: string; count: number; description: string };
// Money on table
overdue: OverdueSlot[];
overdueTotal: string;
// Projections
quarterPace?: string; // "On pace for 450,000 kr this Q1 — 18% ahead"
yoyRevenue?: string; // "up 35% vs last year"
yoyProfit?: string;
};
To prevent AI misinterpretation, profit changes are pre-computed with semantic accuracy:
| Previous | Current | Change | Description |
|---|---|---|---|
| -189,000 | -7,000 | +96% | "Loss decreased from -189,000 to -7,000" |
| -50,000 | 10,000 | +120% | "Turned from -50,000 loss to 10,000 profit" |
| 100,000 | 80,000 | -20% | "Down 20% vs last week" |
| 0 | 50,000 | - | "New profit of 50,000" |
This prevents the AI from saying "profit nearly doubled" when a loss merely decreased.
The selectTopMetrics() function chooses the 4 most relevant metrics:
Score each metric based on:
Ensure diversity:
Fill gaps with state metrics (for quiet weeks):
| Category | Metrics |
|---|---|
profitability | Profit, Profit Margin |
revenue | Revenue, Cash Flow |
expenses | Total Expenses |
operational | Runway |
receivables | Overdue Amount |
balance | Cash Balance |
Calculates specific date when cash runs out:
// Uses period end date for historical accuracy
const baseDate = context?.periodEnd ?? new Date();
const exhaustionDate = new Date(baseDate);
exhaustionDate.setDate(exhaustionDate.getDate() + Math.round(runway * 30));
// Result: "March 3, 2026"
Conditions:
Projects full-quarter revenue based on current pace:
// Example: 3 weeks into Q1 with 150,000 kr revenue
// Q1 has 13 weeks total
const projectedRevenue = (qtdRevenue / daysElapsed) * totalQuarterDays;
// "On pace for 450,000 kr this Q1 — 18% ahead of Q1 last year"
Requirements:
Flags unusual payment delays from typically fast-paying customers:
// Customer typically pays in 5 days, now 15 days overdue
const unusualThreshold = Math.max(typicalPayDays * 1.5, typicalPayDays + 7);
const isUnusual = daysOverdue > unusualThreshold;
// Result: "⚠️ UNUSUAL - typically pays in 5 days"
Requirements:
Identifies consecutive week patterns:
| Streak Type | Trigger | Example |
|---|---|---|
profitable | 3+ profitable weeks | "Third consecutive profitable week" |
revenue_growth | 3+ weeks of growth | "Revenue up for 4 weeks straight" |
revenue_decline | 2+ weeks of decline | "Second week of declining revenue" |
invoices_paid_on_time | 3+ weeks, requires actual invoices | "All invoices paid on time for 5 weeks" |
The system ensures mathematical consistency at multiple points:
fetchMetricData)// If spending query returns 0 but profit is negative, derive expenses
if (expenses === 0 && profit < 0 && revenue >= 0) {
expenses = revenue - profit;
}
computeSlots)// If revenue is 0 but profit + expenses > 0, derive revenue
if (revenueRaw === 0 && profitRaw > 0 && expensesRaw > 0) {
revenueRaw = profitRaw + expensesRaw;
}
<accuracy>
- If profit is negative, expenses MUST be mentioned
- Profit of 0 is "no activity", NOT a loss
- If profit is NEGATIVE, never say it "improved" or "doubled"
</accuracy>
// Default model optimized for instruction following + cost
const model = "gpt-4.1-mini";
| Constraint | Title | Summary | Story | Actions |
|---|---|---|---|---|
| Word count | 15-25 | 40-60 | 25-40 | N/A |
| Banned words | solid, healthy, strong... | same | same | N/A |
| Required elements | profit, runway | profit, revenue, margin, runway | highlight | overdue invoices first |
// Anomaly detection
const SIGNIFICANT_CHANGE_THRESHOLD = 20; // 20% change is significant
const LOW_RUNWAY_WARNING = 6; // months
const CRITICAL_RUNWAY_ALERT = 3; // months
// Streak detection
const MIN_STREAK_FOR_HIGHLIGHT = 3; // weeks (except revenue_decline = 2)
// Concentration risk
const CONCENTRATION_WARNING_THRESHOLD = 0.7; // 70% from one customer
The insights package uses Evalite for AI output testing:
cd packages/insights
bun run eval
| Scorer | Type | What it checks |
|---|---|---|
titleWordCount | Deterministic | 15-25 words |
summaryWordCount | Deterministic | 40-60 words |
bannedPhrases | Deterministic | No filler words |
profitAccuracy | Deterministic | Negative profit mentions expenses |
runwayDateMentioned | Deterministic | Short runway includes date |
quarterPaceMentioned | Deterministic | Quarter pace in summary when available |
overallQuality | LLM-as-judge | Natural, actionable, accurate |
14 test fixtures covering various scenarios:
| File | Purpose |
|---|---|
packages/insights/src/index.ts | Main InsightsService orchestrator |
packages/insights/src/content/generator.ts | AI content generation |
packages/insights/src/content/prompts/slots.ts | Data pre-processing |
packages/insights/src/content/prompts/title.ts | Title prompt |
packages/insights/src/content/prompts/summary.ts | Summary prompt |
packages/insights/src/content/prompts/actions.ts | Actions prompt |
packages/insights/src/content/prompts/story.ts | Story prompt |
packages/insights/src/metrics/calculator.ts | Metric calculations |
packages/insights/src/metrics/analyzer.ts | Metric selection & anomaly detection |
packages/db/src/queries/insights.ts | Database queries & historical context |
packages/insights/evals/insight.eval.ts | Evalite test definitions |
packages/insights/evals/fixtures.ts | Test data fixtures |
packages/insights/evals/scorers.ts | Custom eval scorers |
Symptom: Summary claims no expenses despite negative profit.
Cause: Spending query returned 0 but mathematical consistency wasn't enforced.
Fix: Data consistency layers should catch this. If still occurring:
fetchMetricData consistency logiccomputeSlots secondary validationprofit = revenue - expensesSymptom: Summary mentions "1 month runway" but no specific date.
Possible Causes:
periodEnd not passed to slots computationDebug:
console.log({
runway: slots.runway,
runwayExhaustionDate: slots.runwayExhaustionDate,
periodEnd: context?.periodEnd
});
Symptom: No quarter pace projection in summary.
Requirements not met:
Verify:
console.log({
quarterPace: slots.quarterPace,
historicalContext: context?.quarterPace
});
Symptom: AI uses growth language when loss merely decreased.
Fix: Ensure profitChangeDescription is being used in prompts:
// Should see pre-computed description like:
"Loss decreased from -189,000 to -7,000"
// Not calculated percentage like:
"+96%"
Symptom: Key metrics display shows "-100% vs last week" for quiet weeks.
This is expected behavior. The raw percentage is mathematically correct. The summary handles this with natural language like "no financial activity this week."
Common issues:
Debug:
cd packages/insights
bun run eval
# Check individual scorer failures in output
Typical insight generation: 2-4 seconds
| Phase | Time |
|---|---|
| Data fetch | 200-500ms |
| Metric calculation | 50-100ms |
| Title + Summary (parallel) | 800-1200ms |
| Actions (parallel with above) | 600-800ms |
| Story | 600-1000ms |
| Total | 2-4s |
Using gpt-4.1-mini: