feat(mood): give the assistant all five values, each with its own age

- the level-A blocks ride the mood aggregate the coach and the briefing
  already share, so the ages are fixed once in the data preparation
  rather than once per sentence
- every block carries its newest value, the day it belongs to, how old
  it is and whether that age still backs a present-tense claim, through
  the same freshness rule the vitals surfaces use
- a stale dimension carries the instruction in words as well as in a
  flag, because that is the sentence this exists to prevent
- the 1-5 scale line is untouched; the new axis describes itself, with
  the anchors resolved from the bundle the person read
- the trend page and the assistant run the same calculator, so a number
  in a sentence cannot disagree with the number on the chart
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 01:43:32 +02:00
1 parent b4b39e9088
commit a6bb4f6577
2 files changed
+246

No files matched your search

@@ -0,0 +1,111 @@
/**
* v1.37 — per-dimension staleness on the level-A blocks.
*
* The failure this exists to prevent has already happened once on the vitals
* surfaces: a value was handed to the narrator with no notion of when it was
* taken, and last week's reading came back as this morning's. The five
* dimensions make that failure easier, not harder, because their ages differ
* — somebody taps a face every morning and opens the detail sliders once a
* week, so pleasantness is fresh while stress is six days old.
*
* The rule under test: every dimension carries its own age, and `current`
* answers "may this back a present-tense sentence" through the one shared
* freshness rule rather than through a comparison written here.
*/
import { describe, expect, it } from "vitest";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import {
TODAY_CLAIM_MAX_AGE_DAYS,
isCurrentForTodayClaim,
} from "@/lib/insights/measurement-freshness";
const TODAY = "2026-08-08";
function dayKey(daysAgo: number): string {
const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`));
d.setUTCDate(d.getUTCDate() - daysAgo);
return d.toISOString().slice(0, 10);
}
/** The shape `buildMoodDimensionFeatures` derives, without a database. */
function blocksFor(
rows: Array<{ daysAgo: number; a1?: number; a2?: number }>,
): Array<{
key: string;
latest: number | null;
newestDaysAgo: number | null;
current: boolean;
}> {
const summaries = computeMoodDimensionSeries(
rows.map((r) => ({
date: dayKey(r.daysAgo),
a1: r.a1 ?? null,
a2: r.a2 ?? null,
a3: null,
a4: null,
a5: null,
})),
TODAY,
);
return summaries
.filter((s) => s.present)
.map((s) => ({
key: s.key,
latest: s.latest,
newestDaysAgo: s.newestDaysAgo,
current: isCurrentForTodayClaim(s.newestDaysAgo),
}));
}
describe("level-A staleness", () => {
it("calls this morning's pleasantness current and a five-day-old stress stale", () => {
// The fixture the whole file is about: one entry today with a mood, one
// five days ago that also answered stress.
const blocks = blocksFor([
{ daysAgo: 0, a1: 8 },
{ daysAgo: 5, a1: 4, a2: 9 },
]);
const a1 = blocks.find((b) => b.key === "a1")!;
const a2 = blocks.find((b) => b.key === "a2")!;
expect(a1.newestDaysAgo).toBe(0);
expect(a1.current).toBe(true);
expect(a2.latest).toBe(9);
expect(a2.newestDaysAgo).toBe(5);
// The claim under test: nothing may say "your stress is high today" from
// this row. It is history and is stated with its date.
expect(a2.current).toBe(false);
});
it("never carries a latest without an age beside it", () => {
// The structural half of the fix. A `latest` alone is exactly what
// produced the wrong sentence, so the shape is asserted, not the value.
for (const block of blocksFor([{ daysAgo: 2, a1: 5, a2: 5 }])) {
expect(block.latest).not.toBeNull();
expect(block.newestDaysAgo).not.toBeNull();
expect(typeof block.current).toBe("boolean");
}
});
it("treats an unknown age as stale rather than as fresh", () => {
// A missing age is not a fresh one. This is the shared rule's own
// behaviour and is asserted here so a local reimplementation that
// defaulted to `true` would be caught.
expect(isCurrentForTodayClaim(null)).toBe(false);
expect(isCurrentForTodayClaim(undefined)).toBe(false);
});
it("puts the boundary exactly where the shared rule puts it", () => {
// Yesterday still backs a present claim; the day before does not. Read
// off the exported constant rather than the literal 1, so a change to the
// window moves this test with it instead of stranding it.
const fresh = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS, a1: 6 }]);
expect(fresh[0].current).toBe(true);
const stale = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS + 1, a1: 6 }]);
expect(stale[0].current).toBe(false);
});
});
+135
View File
@@ -13,6 +13,11 @@ import {
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -64,6 +69,124 @@ interface DataCoverage {
newestDaysAgo: number;
}
/**
* One level-A dimension as the model reads it.
*
* `latest` never travels without `newestDaysAgo` and `current` beside it. A
* value with no age is exactly what produced a reading from last week stated
* as this morning's, so the age is part of the shape rather than something a
* caller is trusted to look up.
*/
export interface MoodDimensionFeature {
key: string;
/** The wording behind the numbers, so a 7 is not read as 7 out of 5. */
scale: string;
/** True when a higher number means a worse day — stress, and only stress. */
inverse: boolean;
avg7: number | null;
avg30: number | null;
latest: number | null;
/** The local day the newest value belongs to. */
latestDate: string | null;
/** Whole days between that day and today. */
newestDaysAgo: number | null;
/**
* Whether that value may back a present-tense claim. False for an absent
* age too: a missing age is not a fresh one.
*/
current: boolean;
/**
* Present only when `current` is false, and says in words what the flag
* says in a boolean. The model reads prose more reliably than it reads a
* field name, and a stale dimension narrated as today's is the one failure
* this block exists to prevent.
*/
staleNotice?: string;
count: number;
}
/**
* The level-A blocks for the model, read once over a bounded window.
*
* The rollup tier caches the mean of `score` and nothing else, so this is its
* own read rather than a widened rollup: a cached tier for four columns that
* may all be NULL would cost a migration to answer a question the live read
* answers in one indexed range scan. If a surface measurably slows, the tier
* grows then, as a read-swap with a fallback on a coverage miss — never as a
* second engine running beside this one.
*
* Only dimensions somebody answered are returned. An empty block would invite
* a sentence about a question that was never asked.
*/
async function buildMoodDimensionFeatures(
userId: string,
): Promise<MoodDimensionFeature[] | undefined> {
const tz = await resolveUserTimezone(userId);
const { t } = getServerTranslator("en");
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const rows = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: {
date: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
},
});
if (rows.length === 0) return undefined;
// The same calculator the trend page uses, so the number in a Coach
// sentence and the number on the chart cannot disagree.
const summaries = computeMoodDimensionSeries(
rows.map((row) => ({
date: row.date,
a1: row.moodA1,
a2: row.stressA2,
a3: row.energyA3,
a4: row.connectionA4,
a5: row.stabilityA5,
})),
userDayKey(new Date(), tz),
);
const blocks = summaries
.filter((summary) => summary.present)
.map((summary) => {
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key)!;
const current = isCurrentForTodayClaim(summary.newestDaysAgo);
return {
key: summary.key,
// The anchors are resolved through the bundle rather than repeated
// here: the model must read the same words the person read when they
// moved the slider, and a second copy of that wording would drift
// from the one on screen the first time either was edited. English,
// like the legacy `scale` line beside it.
scale: `${t(dimension.labelKey)}, 0-10: 0 = ${t(dimension.lowAnchorKey)}, 10 = ${t(dimension.highAnchorKey)}`,
inverse: summary.inverse,
avg7: summary.avg7,
avg30: summary.avg30,
latest: summary.latest,
latestDate: summary.latestDate,
newestDaysAgo: summary.newestDaysAgo,
current,
...(current
? {}
: {
staleNotice: summary.latestDate
? `Last answered on ${summary.latestDate}. State it as history with that date; do not describe it as today.`
: "No dated value. Do not describe this dimension as current.",
}),
count: summary.count,
};
});
return blocks.length > 0 ? blocks : undefined;
}
export interface AggregatedFeatures {
weight?: {
latest: number;
@@ -161,6 +284,17 @@ export interface AggregatedFeatures {
trend30: "improving" | "declining" | "stable" | null;
totalEntries: number;
coverage: DataCoverage;
/**
* v1.37 — the five level-A self-state values, one block each, and only for
* the dimensions somebody actually answered. `scale` above describes the
* legacy 1-5 axis and is handed to the model verbatim, so it is left
* exactly as it was; these carry their own scale line beside it.
*
* Every block states its own age, because the ages differ: stress can be
* six days old while pleasantness was answered this morning, and a model
* given one age for the pair narrates the stale one as today's.
*/
dimensions?: MoodDimensionFeature[];
};
sleep?: {
avg7: number | null;
@@ -1055,6 +1189,7 @@ export async function extractFeatures(
latest: moodLatestScore,
trend30,
totalEntries: moodTotalEntries,
dimensions: await buildMoodDimensionFeatures(userId),
coverage: {
count: moodTotalEntries,
spanDays,