feat(insights): fold structured mood tags into the tag takeaways

The structured taxonomy tags carry a separate, richer signal than the
free-text tag list: a curated catalog pick with a label key and category,
attached to a mood entry through the tag-link join. The tag-mood takeaway
builder only ever read the flat tags, so the structured tags never reached
the ranked lift/drop pool despite both call sites already passing them.

Merge both axes into one ranked pool. A structured candidate emits its
catalog label key as tagKey, which the feed resolves to the active locale
the same way the weekday key already resolves; flat tags keep their
verbatim label. The lean status snapshot now loads the structured-tag
links too, so the takeaways the model sees match the ones on screen.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 23:43:39 +02:00
1 parent d1e6befa5c
commit 38867439f3
5 files changed
+161 -12

No files matched your search

@@ -34,6 +34,22 @@ describe("<MoodNarrativeFeed>", () => {
expect(html).toContain('data-slot="mood-narrative-feed"');
});
it("resolves a structured tag key into the sentence", () => {
const items: MoodNarrativeItem[] = [
{
kind: "tag-lift",
messageKey: "insights.mood.narrative.tagLift",
vars: { tagKey: "charts.weekdaysFull.fri", delta: "0.6" },
},
];
// `charts.weekdaysFull.fri` stands in for a catalog label key — the
// renderer must resolve `tagKey` the same way it resolves `weekdayKey`.
const html = render(<MoodNarrativeFeed items={items} />);
expect(html).toContain("Friday");
expect(html).not.toContain("charts.weekdaysFull.fri");
expect(html).toContain("0.6");
});
it("renders one row per takeaway in order", () => {
const items: MoodNarrativeItem[] = [
{
@@ -70,9 +70,12 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
{items.map((item, index) => {
const Icon = pickIcon(item);
// The weekday key is itself an i18n key — resolve it before
// interpolating so the sentence reads in the active locale.
// interpolating so the sentence reads in the active locale. A
// structured tag→mood takeaway carries its catalog `tagKey`
// (an i18n key) the same way; flat tags supply `tag` verbatim.
const vars: Record<string, string> = { ...item.vars };
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
if (vars.tagKey) vars.tag = t(vars.tagKey);
return (
<li
key={`${item.kind}-${index}`}
@@ -177,6 +177,80 @@ describe("computeMoodNarratives — anti-platitude thresholds", () => {
);
});
it("surfaces a structured tag→mood lift via its catalog label key", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
daily: daily([
{ dayOffset: 0, value: 3 },
{ dayOffset: 1, value: 3 },
{ dayOffset: 2, value: 3 },
]),
structuredTags: [
{
key: "happy",
categoryKey: "feelings",
labelKey: "insights.mood.tags.feelings.happy",
icon: null,
count: MOOD_NARRATIVE_MIN_TAG_COUNT,
avgScore: 4.5,
},
],
};
const out = computeMoodNarratives(input);
const lift = out.find((n) => n.kind === "tag-lift");
// structured tags carry an i18n key the renderer resolves, never a
// raw label string.
expect(lift?.vars.tagKey).toBe("insights.mood.tags.feelings.happy");
expect(lift?.vars.tag).toBeUndefined();
});
it("ranks flat and structured tags in one shared pool", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
daily: daily([
{ dayOffset: 0, value: 3 },
{ dayOffset: 1, value: 3 },
{ dayOffset: 2, value: 3 },
]),
tags: [{ tag: "sport", count: MOOD_NARRATIVE_MIN_TAG_COUNT, avgScore: 4 }],
structuredTags: [
{
key: "celebrating",
categoryKey: "events",
labelKey: "insights.mood.tags.events.celebrating",
icon: null,
count: MOOD_NARRATIVE_MIN_TAG_COUNT,
avgScore: 5, // stronger lift than the flat "sport" tag
},
],
};
const lift = computeMoodNarratives(input).find((n) => n.kind === "tag-lift");
// the stronger structured lift wins the single tag-lift slot.
expect(lift?.vars.tagKey).toBe("insights.mood.tags.events.celebrating");
});
it("ignores a structured tag below the occurrence gate", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
daily: daily([
{ dayOffset: 0, value: 3 },
{ dayOffset: 1, value: 3 },
]),
structuredTags: [
{
key: "rare",
categoryKey: "events",
labelKey: "insights.mood.tags.events.rare",
icon: null,
count: MOOD_NARRATIVE_MIN_TAG_COUNT - 1,
avgScore: 5,
},
],
};
const out = computeMoodNarratives(input);
expect(out.some((n) => n.kind === "tag-lift")).toBe(false);
});
it("stays silent on a tag whose delta is below the effect threshold", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
+33 -7
View File
@@ -230,17 +230,27 @@ function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null {
}
/**
* Top tag→mood deltas against the overall daily mean. Flat tags and
* structured tags share one ranked pool; the strongest lift and the
* strongest drop survive (capped). Each needs the min occurrence count
* and an effect that clears the magnitude threshold.
* Top tag→mood deltas against the overall daily mean. Flat free-text tags
* AND structured taxonomy tags share one ranked pool: the strongest lift
* and the strongest drop across both axes survive (capped). Each candidate
* needs the min occurrence count and an effect that clears the magnitude
* threshold.
*
* The two axes carry different label shapes. A flat tag's label is the
* raw free-text string, emitted as `vars.tag` and rendered verbatim. A
* structured tag's label is an i18n message key from the catalog, emitted
* as `vars.tagKey` so the renderer resolves it in the active locale
* (mirroring the `weekdayKey` → `weekday` pattern) before interpolation.
*/
function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
const overall = mean(input.daily.map((b) => b.value));
if (overall == null) return [];
type Candidate = {
label: string;
/** Free-text label for flat tags; null for structured (use `labelKey`). */
label: string | null;
/** i18n key for structured tags; null for flat (use `label`). */
labelKey: string | null;
delta: number;
count: number;
};
@@ -250,6 +260,16 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue;
candidates.push({
label: row.tag,
labelKey: null,
delta: round(row.avgScore - overall, 2),
count: row.count,
});
}
for (const row of input.structuredTags) {
if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue;
candidates.push({
label: null,
labelKey: row.labelKey,
delta: round(row.avgScore - overall, 2),
count: row.count,
});
@@ -262,13 +282,16 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
.filter((c) => c.delta <= -MOOD_NARRATIVE_MIN_EFFECT)
.sort((a, b) => a.delta - b.delta);
const tagVars = (c: Candidate): Record<string, string> =>
c.labelKey != null ? { tagKey: c.labelKey } : { tag: c.label ?? "" };
const out: MoodNarrative[] = [];
const topLift = lifts[0];
if (topLift) {
out.push({
kind: "tag-lift",
messageKey: "insights.mood.narrative.tagLift",
vars: { tag: topLift.label, delta: topLift.delta.toFixed(1) },
vars: { ...tagVars(topLift), delta: topLift.delta.toFixed(1) },
strength: topLift.delta,
});
}
@@ -277,7 +300,10 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
out.push({
kind: "tag-drop",
messageKey: "insights.mood.narrative.tagDrop",
vars: { tag: topDrop.label, delta: Math.abs(topDrop.delta).toFixed(1) },
vars: {
...tagVars(topDrop),
delta: Math.abs(topDrop.delta).toFixed(1),
},
strength: Math.abs(topDrop.delta),
});
}
+34 -4
View File
@@ -13,6 +13,7 @@ import {
MOOD_ORANGE_MAX,
MOOD_ORANGE_MIN,
computeInTargetPct,
computeStructuredTagSummary,
computeTagSummary,
computeWeekdayAverages,
pairDailyBuckets,
@@ -115,9 +116,37 @@ export async function generateMoodStatusForUser(
score: true,
tags: true,
moodLoggedAt: true,
// v1.8.6 — pull the structured-tag links so the snapshot feeds
// `computeMoodNarratives` the same structured-tag pool the visible
// mood page does (shown == sent for the tag→mood takeaways).
tagLinks: {
select: {
moodTag: {
select: {
key: true,
labelKey: true,
icon: true,
category: { select: { key: true } },
},
},
},
},
},
})
.then((rows) => rows.reverse());
.then((rows) =>
rows.reverse().map((row) => ({
date: row.date,
score: row.score,
tags: row.tags,
moodLoggedAt: row.moodLoggedAt,
structuredTags: (row.tagLinks ?? []).map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
})),
})),
);
const now = new Date();
@@ -224,17 +253,18 @@ export async function generateMoodStatusForUser(
// tag patterns. Shared with the `/api/mood/insights` tag breakdown
// via `computeTagSummary` so the prose and the chart never drift.
const tagSummary = computeTagSummary(entries, now);
const structuredTagSummary = computeStructuredTagSummary(entries, now);
// v1.8.6 — the same threshold-gated narrative feed the user sees on
// the mood page, computed from the same aggregates so the prose the
// model writes never contradicts the takeaways on screen (shown ==
// sent). Structured tags are not loaded on this lean snapshot path,
// so the tag deltas ride the flat-tag summary only.
// sent). Both the flat free-text tags and the structured taxonomy tags
// feed the tag→mood pool, matching the visible feed exactly.
const narratives = computeMoodNarratives({
daily: moodSeries.daily,
weekday: computeWeekdayAverages(moodSeries.daily, now),
tags: tagSummary,
structuredTags: [],
structuredTags: structuredTagSummary,
inTargetPct: inTargetPctLast30DailyPoints,
loggedDayKeys: Array.from(new Set(entries.map((entry) => entry.date))),
now,