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