mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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(),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user