api: lift the weekly insight math out of its Prisma wrappers
The mood-trend delta, the sleep/energy correlation, and the trigger distribution sat behind an await, so reaching any of them meant a running database. Taking the rows the select already returns and handing back the metadata and body instead makes them callable, and the new pure tests pin the delta, the correlation's boundaries, and the copy text. The processors keep their fetch and upsert, so the stored Insight does not change
Pedro Lucas Porcellis porcellis@eletrotupi.com · 10 days ago
7ff50aa722f838fa59d0afb52d1c2e2b96bf3e22 Browse filesParents
82d1a5fChanged files
6
Additions
+333
Deletions
-78
api/src/lib/queue/processors/insights/energySleep.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 4
5 -
// Pearson correlation coefficient, returns value in [-1, 1]
6 -
// TODO: Document this more and write to thesis
7 -
function pearson(xs: number[], ys: number[]): number {
5 +
// Pearson correlation coefficient for two paired samples, in [-1, 1]. Returns
6 +
// 0 when there are fewer than two samples or either axis has no variance, so
7 +
// an absent signal reads as no correlation rather than a divide by zero.
8 +
export function pearson(xs: number[], ys: number[]): number {
8 9
const n = xs.length;
9 10
if (n < 2) return 0;
10 11
11 12
const meanX = xs.reduce((a, b) => a + b, 0) / n;
12 13
const meanY = ys.reduce((a, b) => a + b, 0) / n;
15 16
const denX = Math.sqrt(xs.reduce((sum, x) => sum + (x - meanX) ** 2, 0));
16 17
const denY = Math.sqrt(ys.reduce((sum, y) => sum + (y - meanY) ** 2, 0));
17 18
18 19
if (denX === 0 || denY === 0) return 0;
19 20
return num / (denX * denY);
21 +
}
22 +
23 +
export function energySleepBody(score: number, sampleSize: number): string {
24 +
const base = sampleSize < 5 ? " (dados limitados, continue registrando)" : "";
25 +
26 +
if (score > 0.6)
27 +
return `Seu sono tem forte impacto positivo na sua energia no dia seguinte.${base}`;
28 +
if (score > 0.3)
29 +
return `Seu sono tem impacto moderado na sua energia no dia seguinte.${base}`;
30 +
if (score < -0.3)
31 +
return `Curiosamente, mais sono está associado a menos energia — vale investigar.${base}`;
32 +
return `Não encontramos uma relação clara entre seu sono e energia ainda.${base}`;
20 33
}
21 34
22 35
export async function processEnergySleep({
23 36
userId,
24 37
periodStart,
68 81
type: InsightType.ENERGY_SLEEP_CORRELATION,
69 82
periodStart: start,
70 83
},
71 84
},
72 85
update: {
73 -
body: buildBody(score, pairs.length),
86 +
body: energySleepBody(score, pairs.length),
74 87
metadata: { correlationScore: score, sampleSize: pairs.length },
75 88
generatedAt: new Date(),
76 89
},
77 90
create: {
78 91
userId,
79 92
type: InsightType.ENERGY_SLEEP_CORRELATION,
80 93
period: InsightPeriod.WEEKLY,
81 94
title: "Sono e Energia",
82 -
body: buildBody(score, pairs.length),
95 +
body: energySleepBody(score, pairs.length),
83 96
metadata: { correlationScore: score, sampleSize: pairs.length },
84 97
periodStart: start,
85 98
periodEnd: end,
86 99
},
87 100
});
88 101
}
89 -
90 -
function buildBody(score: number, sampleSize: number): string {
91 -
const base = sampleSize < 5 ? " (dados limitados, continue registrando)" : "";
92 -
93 -
if (score > 0.6)
94 -
return `Seu sono tem forte impacto positivo na sua energia no dia seguinte.${base}`;
95 -
if (score > 0.3)
96 -
return `Seu sono tem impacto moderado na sua energia no dia seguinte.${base}`;
97 -
if (score < -0.3)
98 -
return `Curiosamente, mais sono está associado a menos energia — vale investigar.${base}`;
99 -
return `Não encontramos uma relação clara entre seu sono e energia ainda.${base}`;
100 -
}
api/src/lib/queue/processors/insights/moodTrend.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod, BaseMoodOption } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 +
import { avg } from "@app/utils/calc";
4 5
5 6
const MOOD_SCORE: Record<BaseMoodOption, number> = {
6 7
GREAT: 5,
7 8
GOOD: 4,
8 9
NEUTRAL: 3,
9 10
SAD: 2,
10 11
ANGRY: 1,
11 12
};
12 13
13 -
const avg = (values: number[]) =>
14 -
values.reduce((a, b) => a + b, 0) / values.length;
15 -
16 -
export async function processMoodTrend({
17 -
userId,
18 -
periodStart,
19 -
periodEnd,
20 -
}: InsightPeriodPayload): Promise<void> {
21 -
const start = new Date(periodStart);
22 -
const end = new Date(periodEnd);
23 -
24 -
const moods = await prisma.mood.findMany({
25 -
where: { userId, moment: { gte: start, lte: end } },
26 -
orderBy: { moment: "asc" },
27 -
select: { selectedMood: true, energyLevel: true, moment: true },
28 -
});
14 +
type MoodTrendEntry = { selectedMood: BaseMoodOption; energyLevel: number };
29 15
30 -
if (moods.length < 3) return;
16 +
export type MoodTrend = {
17 +
delta: number;
18 +
avgFirst: number;
19 +
avgSecond: number;
20 +
dominantMood: BaseMoodOption;
21 +
avgEnergy: number;
22 +
};
31 23
24 +
export function computeMoodTrend(moods: MoodTrendEntry[]): MoodTrend {
32 25
const scores = moods.map((m) => MOOD_SCORE[m.selectedMood]);
33 26
const mid = Math.floor(scores.length / 2);
34 27
const avgFirst = avg(scores.slice(0, mid));
35 28
const avgSecond = avg(scores.slice(mid));
36 29
const delta = Math.round(((avgSecond - avgFirst) / avgFirst) * 100);
44 37
Object.entries(moodCounts) as [BaseMoodOption, number][]
45 38
).sort((a, b) => b[1] - a[1])[0][0];
46 39
47 40
const avgEnergy = avg(moods.map((m) => m.energyLevel));
48 41
42 +
return { delta, avgFirst, avgSecond, dominantMood, avgEnergy };
43 +
}
44 +
45 +
export function moodTrendBody(delta: number): string {
46 +
if (delta > 10)
47 +
return `Seu humor melhorou ${delta}% em relação ao início do período.`;
48 +
if (delta < -10)
49 +
return `Seu humor caiu ${Math.abs(delta)}% em relação ao início do período.`;
50 +
return "Seu humor se manteve estável durante o período.";
51 +
}
52 +
53 +
export async function processMoodTrend({
54 +
userId,
55 +
periodStart,
56 +
periodEnd,
57 +
}: InsightPeriodPayload): Promise<void> {
58 +
const start = new Date(periodStart);
59 +
const end = new Date(periodEnd);
60 +
61 +
const moods = await prisma.mood.findMany({
62 +
where: { userId, moment: { gte: start, lte: end } },
63 +
orderBy: { moment: "asc" },
64 +
select: { selectedMood: true, energyLevel: true, moment: true },
65 +
});
66 +
67 +
if (moods.length < 3) return;
68 +
69 +
const trend = computeMoodTrend(moods);
70 +
49 71
await prisma.insight.upsert({
50 72
where: {
51 73
userId_type_periodStart: {
52 74
userId,
53 75
type: InsightType.MOOD_TREND,
54 76
periodStart: start,
55 77
},
56 78
},
57 79
update: {
58 -
body: buildBody(delta),
59 -
metadata: { delta, avgFirst, avgSecond, dominantMood, avgEnergy },
80 +
body: moodTrendBody(trend.delta),
81 +
metadata: trend,
60 82
generatedAt: new Date(),
61 83
},
62 84
create: {
63 85
userId,
64 86
type: InsightType.MOOD_TREND,
65 87
period: InsightPeriod.WEEKLY,
66 88
title: "Tendência de Humor",
67 -
body: buildBody(delta),
68 -
metadata: { delta, avgFirst, avgSecond, dominantMood, avgEnergy },
89 +
body: moodTrendBody(trend.delta),
90 +
metadata: trend,
69 91
periodStart: start,
70 92
periodEnd: end,
71 93
},
72 94
});
73 95
}
74 -
75 -
function buildBody(delta: number): string {
76 -
if (delta > 10)
77 -
return `Seu humor melhorou ${delta}% em relação ao início do período.`;
78 -
if (delta < -10)
79 -
return `Seu humor caiu ${Math.abs(delta)}% em relação ao início do período.`;
80 -
return "Seu humor se manteve estável durante o período.";
81 -
}
api/src/lib/queue/processors/insights/triggerPattern.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod, TriggerType } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 4
5 5
const TRIGGER_LABEL: Record<TriggerType, string> = {
11 11
THERAPY: "terapia",
12 12
INTERNAL: "interno",
13 13
OTHER: "outros",
14 14
};
15 15
16 +
// Category counts for a period, most frequent first
17 +
export function countTriggerCategories(
18 +
triggers: { category: TriggerType }[],
19 +
): [TriggerType, number][] {
20 +
const counts = triggers.reduce<Partial<Record<TriggerType, number>>>(
21 +
(acc, t) => ({ ...acc, [t.category]: (acc[t.category] ?? 0) + 1 }),
22 +
{},
23 +
);
24 +
25 +
return (Object.entries(counts) as [TriggerType, number][]).sort(
26 +
(a, b) => b[1] - a[1],
27 +
);
28 +
}
29 +
30 +
export function triggerPatternBody(
31 +
top: TriggerType,
32 +
count: number,
33 +
total: number,
34 +
): string {
35 +
const pct = Math.round((count / total) * 100);
36 +
const label = TRIGGER_LABEL[top];
37 +
let template = "";
38 +
39 +
if (top == "OTHER") {
40 +
template = `
41 +
Gatilhos do tipo "${label}" foram os mais frequentes,
42 +
representando ${pct}% dos registros do período.
43 +
`
44 +
} else {
45 +
template = `
46 +
Gatilhos de ${label} foram os mais frequentes,
47 +
representando ${pct}% dos registros do período.
48 +
`
49 +
}
50 +
51 +
return template.replace(/\s*\n\s*/g, " ").trim();
52 +
}
53 +
16 54
export async function processTriggerPattern({
17 55
userId,
18 56
periodStart,
19 57
periodEnd,
20 58
}: InsightPeriodPayload): Promise<void> {
26 64
select: { category: true },
27 65
});
28 66
29 67
if (triggers.length === 0) return;
30 68
31 -
// Count by category
32 -
const counts = triggers.reduce<Partial<Record<TriggerType, number>>>(
33 -
(acc, t) => ({ ...acc, [t.category]: (acc[t.category] ?? 0) + 1 }),
34 -
{},
35 -
);
36 -
37 -
const sorted = (Object.entries(counts) as [TriggerType, number][]).sort(
38 -
(a, b) => b[1] - a[1],
39 -
);
40 -
69 +
const sorted = countTriggerCategories(triggers);
41 70
const [topCategory, topCount] = sorted[0];
42 71
const distribution = Object.fromEntries(sorted);
43 72
44 73
await prisma.insight.upsert({
45 74
where: {
48 77
type: InsightType.TRIGGER_PATTERN,
49 78
periodStart: start,
50 79
},
51 80
},
52 81
update: {
53 -
body: buildBody(topCategory, topCount, triggers.length),
82 +
body: triggerPatternBody(topCategory, topCount, triggers.length),
54 83
metadata: {
55 84
topCategory,
56 85
topCount,
57 86
total: triggers.length,
58 87
distribution,
62 91
create: {
63 92
userId,
64 93
type: InsightType.TRIGGER_PATTERN,
65 94
period: InsightPeriod.WEEKLY,
66 95
title: "Padrão de Gatilhos",
67 -
body: buildBody(topCategory, topCount, triggers.length),
96 +
body: triggerPatternBody(topCategory, topCount, triggers.length),
68 97
metadata: {
69 98
topCategory,
70 99
topCount,
71 100
total: triggers.length,
72 101
distribution,
74 103
periodStart: start,
75 104
periodEnd: end,
76 105
},
77 106
});
78 107
}
79 -
80 -
function buildBody(top: TriggerType, count: number, total: number): string {
81 -
const pct = Math.round((count / total) * 100);
82 -
const label = TRIGGER_LABEL[top];
83 -
let template = "";
84 -
85 -
if (top == "OTHER") {
86 -
template = `
87 -
Gatilhos do tipo "${label}" foram os mais frequentes,
88 -
representando ${pct}% dos registros do período.
89 -
`
90 -
} else {
91 -
template = `
92 -
Gatilhos de ${label} foram os mais frequentes,
93 -
representando ${pct}% dos registros do período.
94 -
`
95 -
}
96 -
97 -
return template.replace(/\s*\n\s*/g, " ").trim();
98 -
}
/dev/null → api/tests/insights/energy-sleep.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
pearson,
4 +
energySleepBody,
5 +
} from '@app/lib/queue/processors/insights/energySleep'
6 +
7 +
describe('pearson', () => {
8 +
it('returns 1 for a perfect positive correlation', () => {
9 +
expect(pearson([1, 2, 3], [2, 4, 6])).toBeCloseTo(1, 12)
10 +
})
11 +
12 +
it('returns -1 for a perfect negative correlation', () => {
13 +
expect(pearson([1, 2, 3], [6, 4, 2])).toBeCloseTo(-1, 12)
14 +
})
15 +
16 +
it('returns 0 with fewer than two samples', () => {
17 +
expect(pearson([], [])).toBe(0)
18 +
expect(pearson([1], [2])).toBe(0)
19 +
})
20 +
21 +
it('returns 0 when either axis has zero variance', () => {
22 +
expect(pearson([5, 5, 5], [1, 2, 3])).toBe(0)
23 +
expect(pearson([1, 2, 3], [4, 4, 4])).toBe(0)
24 +
})
25 +
26 +
it('stays within [-1, 1] for a partial correlation', () => {
27 +
const r = pearson([1, 2, 3, 4, 5], [2, 1, 3, 4, 6])
28 +
29 +
expect(r).toBeGreaterThanOrEqual(-1)
30 +
expect(r).toBeLessThanOrEqual(1)
31 +
expect(r).toBeCloseTo(0.904, 3)
32 +
})
33 +
})
34 +
35 +
describe('energySleepBody', () => {
36 +
const limited = ' (dados limitados, continue registrando)'
37 +
38 +
it('reads a strong positive correlation above 0.6', () => {
39 +
expect(energySleepBody(0.8, 10)).toBe(
40 +
'Seu sono tem forte impacto positivo na sua energia no dia seguinte.',
41 +
)
42 +
})
43 +
44 +
it('reads a moderate correlation above 0.3', () => {
45 +
expect(energySleepBody(0.4, 10)).toBe(
46 +
'Seu sono tem impacto moderado na sua energia no dia seguinte.',
47 +
)
48 +
})
49 +
50 +
it('reads a negative correlation below -0.3', () => {
51 +
expect(energySleepBody(-0.5, 10)).toBe(
52 +
'Curiosamente, mais sono está associado a menos energia — vale investigar.',
53 +
)
54 +
})
55 +
56 +
it('reports no clear relation in the middle band', () => {
57 +
expect(energySleepBody(0, 10)).toBe(
58 +
'Não encontramos uma relação clara entre seu sono e energia ainda.',
59 +
)
60 +
})
61 +
62 +
it('falls to the lower band exactly on each threshold', () => {
63 +
expect(energySleepBody(0.6, 10)).toBe(
64 +
'Seu sono tem impacto moderado na sua energia no dia seguinte.',
65 +
)
66 +
expect(energySleepBody(0.3, 10)).toBe(
67 +
'Não encontramos uma relação clara entre seu sono e energia ainda.',
68 +
)
69 +
expect(energySleepBody(-0.3, 10)).toBe(
70 +
'Não encontramos uma relação clara entre seu sono e energia ainda.',
71 +
)
72 +
})
73 +
74 +
it('appends the low-sample footnote under five pairs', () => {
75 +
expect(energySleepBody(0.8, 3)).toBe(
76 +
'Seu sono tem forte impacto positivo na sua energia no dia seguinte.' +
77 +
limited,
78 +
)
79 +
})
80 +
81 +
it('drops the footnote at exactly five pairs', () => {
82 +
expect(energySleepBody(0.8, 4)).toBe(
83 +
'Seu sono tem forte impacto positivo na sua energia no dia seguinte.' +
84 +
limited,
85 +
)
86 +
expect(energySleepBody(0.8, 5)).toBe(
87 +
'Seu sono tem forte impacto positivo na sua energia no dia seguinte.',
88 +
)
89 +
})
90 +
})
/dev/null → api/tests/insights/mood-trend.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { BaseMoodOption } from '@prisma/client'
3 +
import {
4 +
computeMoodTrend,
5 +
moodTrendBody,
6 +
} from '@app/lib/queue/processors/insights/moodTrend'
7 +
8 +
const mood = (selectedMood: BaseMoodOption, energyLevel: number) => ({
9 +
selectedMood,
10 +
energyLevel,
11 +
})
12 +
13 +
describe('computeMoodTrend', () => {
14 +
it('averages each half, scales the delta, and picks the dominant mood', () => {
15 +
const trend = computeMoodTrend([
16 +
mood('SAD', 6),
17 +
mood('NEUTRAL', 6),
18 +
mood('GREAT', 6),
19 +
mood('GREAT', 6),
20 +
mood('GREAT', 6),
21 +
])
22 +
23 +
expect(trend).toEqual({
24 +
delta: 100,
25 +
avgFirst: 2.5,
26 +
avgSecond: 5,
27 +
dominantMood: 'GREAT',
28 +
avgEnergy: 6,
29 +
})
30 +
})
31 +
32 +
it('reports a negative delta when the second half dips', () => {
33 +
const trend = computeMoodTrend([
34 +
mood('GREAT', 6),
35 +
mood('GREAT', 6),
36 +
mood('SAD', 6),
37 +
mood('SAD', 6),
38 +
mood('SAD', 6),
39 +
])
40 +
41 +
expect(trend.delta).toBe(-60)
42 +
expect(trend.dominantMood).toBe('SAD')
43 +
})
44 +
45 +
it('reports a zero delta and the mean energy for a flat period', () => {
46 +
const trend = computeMoodTrend([
47 +
mood('GOOD', 3),
48 +
mood('GOOD', 9),
49 +
mood('GOOD', 6),
50 +
])
51 +
52 +
expect(trend.delta).toBe(0)
53 +
expect(trend.avgEnergy).toBe(6)
54 +
})
55 +
})
56 +
57 +
describe('moodTrendBody', () => {
58 +
it('celebrates an improving delta', () => {
59 +
expect(moodTrendBody(100)).toBe(
60 +
'Seu humor melhorou 100% em relação ao início do período.',
61 +
)
62 +
})
63 +
64 +
it('names a worsening delta by its magnitude', () => {
65 +
expect(moodTrendBody(-60)).toBe(
66 +
'Seu humor caiu 60% em relação ao início do período.',
67 +
)
68 +
})
69 +
70 +
it('calls a small delta stable', () => {
71 +
expect(moodTrendBody(5)).toBe(
72 +
'Seu humor se manteve estável durante o período.',
73 +
)
74 +
})
75 +
76 +
it('treats a delta of exactly ten as stable and eleven as a change', () => {
77 +
expect(moodTrendBody(10)).toBe(
78 +
'Seu humor se manteve estável durante o período.',
79 +
)
80 +
expect(moodTrendBody(-10)).toBe(
81 +
'Seu humor se manteve estável durante o período.',
82 +
)
83 +
expect(moodTrendBody(11)).toContain('melhorou')
84 +
expect(moodTrendBody(-11)).toContain('caiu')
85 +
})
86 +
})
/dev/null → api/tests/insights/trigger-pattern.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { TriggerType } from '@prisma/client'
3 +
import {
4 +
countTriggerCategories,
5 +
triggerPatternBody,
6 +
} from '@app/lib/queue/processors/insights/triggerPattern'
7 +
8 +
const trigger = (category: TriggerType) => ({ category })
9 +
10 +
describe('countTriggerCategories', () => {
11 +
it('counts each category and sorts the most frequent first', () => {
12 +
const sorted = countTriggerCategories([
13 +
trigger('WORK'),
14 +
trigger('SOCIAL'),
15 +
trigger('WORK'),
16 +
trigger('OTHER'),
17 +
trigger('WORK'),
18 +
])
19 +
20 +
expect(sorted).toEqual([
21 +
['WORK', 3],
22 +
['SOCIAL', 1],
23 +
['OTHER', 1],
24 +
])
25 +
})
26 +
27 +
it('leads with the top category', () => {
28 +
const [top] = countTriggerCategories([
29 +
trigger('HEALTH'),
30 +
trigger('HEALTH'),
31 +
trigger('SOCIAL'),
32 +
])
33 +
34 +
expect(top).toEqual(['HEALTH', 2])
35 +
})
36 +
37 +
it('returns an empty distribution for no triggers', () => {
38 +
expect(countTriggerCategories([])).toEqual([])
39 +
})
40 +
})
41 +
42 +
describe('triggerPatternBody', () => {
43 +
it('names the category and its share of the period', () => {
44 +
expect(triggerPatternBody('WORK', 3, 6)).toBe(
45 +
'Gatilhos de trabalho foram os mais frequentes, representando 50% dos registros do período.',
46 +
)
47 +
})
48 +
49 +
it('wraps an OTHER category in the generic wording', () => {
50 +
expect(triggerPatternBody('OTHER', 2, 4)).toBe(
51 +
'Gatilhos do tipo "outros" foram os mais frequentes, ' +
52 +
'representando 50% dos registros do período.',
53 +
)
54 +
})
55 +
})