mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
refactor(insights): reduce the mood card to its snapshot and its relation board
This commit is contained in:
1 parent
87a35b670d
commit
6f3ad9defa
3 files changed
+315
-451
No files matched your search
@@ -44,7 +44,6 @@ vi.mock("@/lib/insights/mood-status", () => ({
|
||||
cached: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
resolveMoodStatusLocale: () => "en",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateMoodStatusForUser,
|
||||
resolveMoodStatusLocale,
|
||||
} from "@/lib/insights/mood-status";
|
||||
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
import { normalizeLocale as resolveMoodStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
+313
-446
@@ -1,19 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
|
||||
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import {
|
||||
formatPreviousContextForPrompt,
|
||||
getPreviousInsightContext,
|
||||
} from "@/lib/insights/memory";
|
||||
import {
|
||||
buildAssessmentContextBlock,
|
||||
computeSteadyRun,
|
||||
pickVarietyLead,
|
||||
} from "@/lib/insights/assessment-context";
|
||||
import { computeSteadyRun } from "@/lib/insights/assessment-context";
|
||||
import { significantPearsonCorrelation } from "@/lib/analytics/correlations";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
type TagInfluenceRow,
|
||||
MOOD_GREEN_MAX,
|
||||
@@ -34,37 +27,26 @@ import {
|
||||
pairDailyBuckets,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import { computeMoodNarratives } from "@/lib/insights/mood-narratives";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
|
||||
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
|
||||
import { buildMetricSignal } from "@/lib/insights/metric-signal";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
finalizeStatusSummary,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
refreshUnchangedStatusInsight,
|
||||
resolveReadOnlyStatusMiss,
|
||||
statusCacheAction,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { round, summarizeSeries } from "@/lib/insights/status-shared";
|
||||
import {
|
||||
runPreparedStatusCard,
|
||||
type PreparedStatusCard,
|
||||
type StatusCardResult,
|
||||
} from "@/lib/insights/status-card-generation";
|
||||
import {
|
||||
coverageSpan,
|
||||
prepareStatusCard,
|
||||
textStatusCodec,
|
||||
type StatusCardBuild,
|
||||
type StatusCardContext,
|
||||
type StatusCardOptions,
|
||||
type StatusCardPlugin,
|
||||
type StatusTextResult,
|
||||
} from "@/lib/insights/status-pipeline";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* Drop the ranking-only `pooledSd` before a tag-influence row enters the
|
||||
@@ -127,117 +109,212 @@ export function crossMetricDailySeries(
|
||||
*/
|
||||
export async function generateMoodStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
|
||||
revalidating?: boolean;
|
||||
}> {
|
||||
options?: StatusCardOptions,
|
||||
): Promise<StatusTextResult> {
|
||||
const prepared = await prepareMoodStatusForUser(userId, options);
|
||||
const result = await runPreparedStatusCard(prepared);
|
||||
return result as {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
revalidating?: boolean;
|
||||
return (await runPreparedStatusCard(prepared)) as StatusTextResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything up to (not including) the provider call, through the shared
|
||||
* status pipeline (`status-pipeline.ts`); this card supplies its snapshot.
|
||||
*/
|
||||
export function prepareMoodStatusForUser(
|
||||
userId: string,
|
||||
options?: StatusCardOptions,
|
||||
): Promise<PreparedStatusCard> {
|
||||
return prepareStatusCard(MOOD_STATUS_CARD, userId, options);
|
||||
}
|
||||
|
||||
type MoodStatusEntry = Awaited<
|
||||
ReturnType<typeof readMoodEntriesForStatus>
|
||||
>[number];
|
||||
type CrossMetricMeasurement = Awaited<
|
||||
ReturnType<typeof readCrossMetricMeasurements>
|
||||
>[number];
|
||||
|
||||
/**
|
||||
* The mood snapshot: the mood series against its band, the tag and factor
|
||||
* boards the mood page shows, the narrative feed, and the cross-metric
|
||||
* context.
|
||||
*/
|
||||
async function buildMoodSnapshot(
|
||||
ctx: StatusCardContext,
|
||||
): Promise<StatusCardBuild<undefined>> {
|
||||
const { userId, locale, userTz, todayKey } = ctx;
|
||||
const entries = await readMoodEntriesForStatus(userId);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// v1.2.5 — every day-bucketing pass below keys on the user's own
|
||||
// calendar day. `userTz` was already resolved above for the cache day-key.
|
||||
|
||||
const moodPoints = entries.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
|
||||
// and correlations; the compact graded series reaches the prompt.
|
||||
const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz });
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now, userTz);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
|
||||
const greenMin = MOOD_GREEN_MIN;
|
||||
const greenMax = MOOD_GREEN_MAX;
|
||||
const orangeMin = MOOD_ORANGE_MIN;
|
||||
const orangeMax = MOOD_ORANGE_MAX;
|
||||
|
||||
// "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29).
|
||||
const inTargetPctLast30DailyPoints = computeInTargetPct(moodSeries.daily);
|
||||
|
||||
// daily[0] = newest bucket (lowest dayOffset).
|
||||
const latestMood = moodSeries.daily[0] ?? null;
|
||||
const previousMood = moodSeries.daily[1] ?? null;
|
||||
|
||||
const { totalSpanDays, newestDaysAgo: newestEntryDaysAgo } = coverageSpan(
|
||||
entries.length > 0 ? entries[0].moodLoggedAt : null,
|
||||
entries.length > 0 ? entries[entries.length - 1].moodLoggedAt : null,
|
||||
);
|
||||
|
||||
const measurements = await readCrossMetricMeasurements(userId, now);
|
||||
const relations = await computeMoodRelations({
|
||||
userId,
|
||||
entries,
|
||||
measurements,
|
||||
moodDaily: moodSeries.daily,
|
||||
inTargetPctLast30DailyPoints,
|
||||
now,
|
||||
userTz,
|
||||
});
|
||||
|
||||
// v1.18.10 — hand the model the finished recent-vs-baseline
|
||||
// comparison + normal-swing verdict instead of asking it to derive them.
|
||||
// Mood reads higher-better against the green band.
|
||||
const moodSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your mood" : "deine Stimmung",
|
||||
unit: "/5",
|
||||
direction: "higher-better",
|
||||
graded: moodGraded,
|
||||
normalRange: { low: greenMin, high: greenMax },
|
||||
newestDaysAgo: newestEntryDaysAgo,
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
generatedForDay: todayKey,
|
||||
focus: "mood",
|
||||
dataCoverage: {
|
||||
totalEntries: entries.length,
|
||||
totalSpanDays,
|
||||
newestEntryDaysAgo,
|
||||
},
|
||||
mood: {
|
||||
...(moodSignal ? { signal: moodSignal } : {}),
|
||||
summary: moodSummary,
|
||||
series: moodGraded,
|
||||
latestDayFocus: latestMood
|
||||
? {
|
||||
dayOffset: latestMood.dayOffset,
|
||||
value: latestMood.value,
|
||||
deltaToPreviousDailyPoint:
|
||||
previousMood == null
|
||||
? null
|
||||
: round(latestMood.value - previousMood.value, 2),
|
||||
}
|
||||
: null,
|
||||
target: {
|
||||
greenMin,
|
||||
greenMax,
|
||||
orangeMin,
|
||||
orangeMax,
|
||||
inTargetPctLast30DailyPoints,
|
||||
},
|
||||
tags: relations.tagSummary.length > 0 ? relations.tagSummary : null,
|
||||
// v1.11.5 — with-vs-without tag influence (only the populated
|
||||
// axes are emitted so the prompt stays compact).
|
||||
tagInfluence:
|
||||
relations.tagInfluence.flat.length > 0 ||
|
||||
relations.tagInfluence.structured.length > 0
|
||||
? {
|
||||
flat:
|
||||
relations.tagInfluence.flat.length > 0
|
||||
? relations.tagInfluence.flat.map(stripRankingOnly)
|
||||
: null,
|
||||
structured:
|
||||
relations.tagInfluence.structured.length > 0
|
||||
? relations.tagInfluence.structured.map(stripRankingOnly)
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
// v1.11.5 — the ranked "better days" board the user sees, so the
|
||||
// model's relations prose matches the on-screen list (shown == sent).
|
||||
betterDays: relations.betterDays.length > 0 ? relations.betterDays : null,
|
||||
// v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch
|
||||
// delta, FDR-gated). Lets the model say "your data shows sleep runs ~X
|
||||
// shorter on days you rate work low (n=N, association not cause)". Only
|
||||
// the surviving rows reach the prompt; an empty board emits null.
|
||||
factorCrosstab:
|
||||
relations.factorCrosstab.length > 0
|
||||
? relations.factorCrosstab.map(stripFactorCrosstab)
|
||||
: null,
|
||||
narratives: relations.narratives.length > 0 ? relations.narratives : null,
|
||||
// v1.9.0 — only emit the daypart pattern when it cleared its
|
||||
// spread/sample floors, so the model never reasons over a once-a-day
|
||||
// logger's single-bucket artefact.
|
||||
timeOfDay: relations.timeOfDay.reliable
|
||||
? {
|
||||
buckets: relations.timeOfDay.buckets,
|
||||
best: relations.timeOfDay.best,
|
||||
worst: relations.timeOfDay.worst,
|
||||
}
|
||||
: null,
|
||||
stability: relations.stability,
|
||||
},
|
||||
crossMetricContext: moodCrossMetricContext(relations),
|
||||
};
|
||||
|
||||
// MOOD is mood-entry backed (not a MeasurementType), so the cross-metric
|
||||
// RELATIONS sub-block is sourced from the snapshot's own crossMetricContext
|
||||
// rather than the measurement discovery engine; relations stays empty here
|
||||
// and the variety / data-strength / repetition signals carry the rotation.
|
||||
return {
|
||||
snapshot,
|
||||
finish: () => ({
|
||||
context: {
|
||||
repeatCount: computeSteadyRun(moodGraded.weekly, moodGraded.monthly),
|
||||
points: moodSeries.daily.length,
|
||||
newestDaysAgo: newestEntryDaysAgo,
|
||||
relationsFor: null,
|
||||
},
|
||||
fallbackText: getNoKeyMoodStatusText(locale, moodSignal),
|
||||
prompts: (snapshotJson, context) => ({
|
||||
systemPrompt: getMoodSystemPrompt(locale),
|
||||
userPrompt: getMoodUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
context.previousContextBlock,
|
||||
context.assessmentContextBlock,
|
||||
context.openerHint,
|
||||
),
|
||||
}),
|
||||
detail: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 — everything up to (not including) the provider call.
|
||||
* Returns a finished `served` result for every path that never needed the
|
||||
* LLM, or a `pending` descriptor the single-card path and the batch path
|
||||
* both drive. See `status-card-generation.ts` for the contract.
|
||||
* The latest 90 mood entries, oldest first, with their structured tags and
|
||||
* their rated factors.
|
||||
*/
|
||||
export async function prepareMoodStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<PreparedStatusCard> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = statusCacheAction("mood", locale);
|
||||
// v1.30.3 — resolve the user's own tz BEFORE the day-key so the
|
||||
// cache rolls over at the user's local midnight, not Berlin's. Has to
|
||||
// happen ahead of the cache read below (the earliest possible return
|
||||
// path), not just the later day-bucketing reads.
|
||||
const userTz = await resolveUserTimezone(userId);
|
||||
const todayKey = userDayKey(new Date(), userTz);
|
||||
|
||||
const cached = await readFreshStatusText({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
force,
|
||||
});
|
||||
if (cached) {
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: true,
|
||||
text: cached.text,
|
||||
cached: true,
|
||||
updatedAt: cached.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "mood",
|
||||
locale,
|
||||
});
|
||||
// Unavailable for any reason serves the deterministic line and enqueues
|
||||
// nothing. `hasProvider` is provider presence only.
|
||||
if (outcome.kind === "unavailable") {
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: outcome.hasProvider,
|
||||
text: getNoKeyMoodStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
// v1.8.7 — stale-while-revalidate: serve the last good assessment
|
||||
// (if any) instantly while the worker re-warms; only fall to the empty
|
||||
// preparing skeleton when none was ever produced.
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: true,
|
||||
text: outcome.lastGood?.text ?? null,
|
||||
cached: outcome.lastGood !== null,
|
||||
updatedAt: outcome.lastGood?.updatedAt ?? null,
|
||||
preparing: outcome.lastGood === null,
|
||||
revalidating: outcome.revalidating,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readMoodEntriesForStatus(userId: string) {
|
||||
// v1.4.28 — cap at 90 entries (~3 months) for the mood
|
||||
// surface. The downstream `applyPayloadBudget` slims further.
|
||||
// Order desc + reverse so the per-row consumers downstream (which
|
||||
// expect oldest-first) see the same shape.
|
||||
const entries = await prisma.moodEntry
|
||||
return prisma.moodEntry
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -318,50 +395,13 @@ export async function prepareMoodStatusForUser(
|
||||
})),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// v1.2.5 — every day-bucketing pass below keys on the user's own
|
||||
// calendar day. `userTz` was already resolved above for the cache day-key.
|
||||
|
||||
const moodPoints = entries.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
|
||||
// and correlations; the compact graded series reaches the prompt.
|
||||
const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz });
|
||||
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now, userTz);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
|
||||
const greenMin = MOOD_GREEN_MIN;
|
||||
const greenMax = MOOD_GREEN_MAX;
|
||||
const orangeMin = MOOD_ORANGE_MIN;
|
||||
const orangeMax = MOOD_ORANGE_MAX;
|
||||
|
||||
// "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29).
|
||||
const inTargetPctLast30DailyPoints = computeInTargetPct(moodSeries.daily);
|
||||
|
||||
// daily[0] = newest bucket (lowest dayOffset).
|
||||
const latestMood = moodSeries.daily[0] ?? null;
|
||||
const previousMood = moodSeries.daily[1] ?? null;
|
||||
|
||||
const oldestEntry = entries.length > 0 ? entries[0].moodLoggedAt : null;
|
||||
const newestEntry =
|
||||
entries.length > 0 ? entries[entries.length - 1].moodLoggedAt : null;
|
||||
const totalSpanDays =
|
||||
oldestEntry && newestEntry
|
||||
? Math.round(
|
||||
(newestEntry.getTime() - oldestEntry.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
const newestEntryDaysAgo = newestEntry
|
||||
? Math.round((Date.now() - newestEntry.getTime()) / (24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
|
||||
/**
|
||||
* The cross-metric measurements the correlations and the factor crosstab
|
||||
* pair against, bounded to the ~365-day window they can pair in.
|
||||
*/
|
||||
async function readCrossMetricMeasurements(userId: string, now: Date) {
|
||||
// Fetch cross-metric context for enrichment. v1.4.28 FB-D2 — cap at
|
||||
// 1095 = 365 d × 3 channels so power users do not pull unbounded
|
||||
// rows. Order desc + reverse so the downstream filters see oldest-
|
||||
@@ -415,6 +455,33 @@ export async function prepareMoodStatusForUser(
|
||||
annotate({ meta: { cross_metric_truncated: true } });
|
||||
}
|
||||
|
||||
return measurements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood against the other metrics and its own tags: the correlations, the
|
||||
* tag summaries and influence, the "better days" board, the factor
|
||||
* crosstab, the time-of-day pattern, stability and the narrative feed — the
|
||||
* same aggregates the visible mood page shows.
|
||||
*/
|
||||
async function computeMoodRelations(args: {
|
||||
userId: string;
|
||||
entries: MoodStatusEntry[];
|
||||
measurements: CrossMetricMeasurement[];
|
||||
moodDaily: DailyBucket[];
|
||||
inTargetPctLast30DailyPoints: number | null;
|
||||
now: Date;
|
||||
userTz: string;
|
||||
}) {
|
||||
const {
|
||||
userId,
|
||||
entries,
|
||||
measurements,
|
||||
inTargetPctLast30DailyPoints,
|
||||
now,
|
||||
userTz,
|
||||
} = args;
|
||||
const moodSeries = { daily: args.moodDaily };
|
||||
const weightSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"WEIGHT",
|
||||
@@ -540,275 +607,75 @@ export async function prepareMoodStatusForUser(
|
||||
tz: userTz,
|
||||
});
|
||||
|
||||
// v1.18.10 — hand the model the finished recent-vs-baseline
|
||||
// comparison + normal-swing verdict instead of asking it to derive them.
|
||||
// Mood reads higher-better against the green band.
|
||||
const moodSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your mood" : "deine Stimmung",
|
||||
unit: "/5",
|
||||
direction: "higher-better",
|
||||
graded: moodGraded,
|
||||
normalRange: { low: greenMin, high: greenMax },
|
||||
newestDaysAgo: newestEntryDaysAgo,
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
generatedForDay: todayKey,
|
||||
focus: "mood",
|
||||
dataCoverage: {
|
||||
totalEntries: entries.length,
|
||||
totalSpanDays,
|
||||
newestEntryDaysAgo,
|
||||
},
|
||||
mood: {
|
||||
...(moodSignal ? { signal: moodSignal } : {}),
|
||||
summary: moodSummary,
|
||||
series: moodGraded,
|
||||
latestDayFocus: latestMood
|
||||
? {
|
||||
dayOffset: latestMood.dayOffset,
|
||||
value: latestMood.value,
|
||||
deltaToPreviousDailyPoint:
|
||||
previousMood == null
|
||||
? null
|
||||
: round(latestMood.value - previousMood.value, 2),
|
||||
}
|
||||
: null,
|
||||
target: {
|
||||
greenMin,
|
||||
greenMax,
|
||||
orangeMin,
|
||||
orangeMax,
|
||||
inTargetPctLast30DailyPoints,
|
||||
},
|
||||
tags: tagSummary.length > 0 ? tagSummary : null,
|
||||
// v1.11.5 — with-vs-without tag influence (only the populated
|
||||
// axes are emitted so the prompt stays compact).
|
||||
tagInfluence:
|
||||
tagInfluence.flat.length > 0 || tagInfluence.structured.length > 0
|
||||
? {
|
||||
flat:
|
||||
tagInfluence.flat.length > 0
|
||||
? tagInfluence.flat.map(stripRankingOnly)
|
||||
: null,
|
||||
structured:
|
||||
tagInfluence.structured.length > 0
|
||||
? tagInfluence.structured.map(stripRankingOnly)
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
// v1.11.5 — the ranked "better days" board the user sees, so the
|
||||
// model's relations prose matches the on-screen list (shown == sent).
|
||||
betterDays: betterDays.length > 0 ? betterDays : null,
|
||||
// v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch
|
||||
// delta, FDR-gated). Lets the model say "your data shows sleep runs ~X
|
||||
// shorter on days you rate work low (n=N, association not cause)". Only
|
||||
// the surviving rows reach the prompt; an empty board emits null.
|
||||
factorCrosstab:
|
||||
factorCrosstab.length > 0
|
||||
? factorCrosstab.map(stripFactorCrosstab)
|
||||
: null,
|
||||
narratives: narratives.length > 0 ? narratives : null,
|
||||
// v1.9.0 — only emit the daypart pattern when it cleared its
|
||||
// spread/sample floors, so the model never reasons over a once-a-day
|
||||
// logger's single-bucket artefact.
|
||||
timeOfDay: timeOfDay.reliable
|
||||
? {
|
||||
buckets: timeOfDay.buckets,
|
||||
best: timeOfDay.best,
|
||||
worst: timeOfDay.worst,
|
||||
}
|
||||
: null,
|
||||
stability,
|
||||
},
|
||||
crossMetricContext:
|
||||
weightSeries.daily.length >= 3 ||
|
||||
sysSeries.daily.length >= 3 ||
|
||||
pulseSeries.daily.length >= 3
|
||||
? {
|
||||
weight:
|
||||
weightSeries.daily.length >= 3
|
||||
? {
|
||||
summary: summarizeSeries(
|
||||
weightSeries.daily.map((bucket) => ({
|
||||
value: bucket.value,
|
||||
})),
|
||||
),
|
||||
correlation: moodVsWeightCorrelation,
|
||||
}
|
||||
: null,
|
||||
bloodPressureSystolic:
|
||||
sysSeries.daily.length >= 3
|
||||
? {
|
||||
summary: summarizeSeries(
|
||||
sysSeries.daily.map((bucket) => ({
|
||||
value: bucket.value,
|
||||
})),
|
||||
),
|
||||
correlation: moodVsSysCorrelation,
|
||||
}
|
||||
: null,
|
||||
pulse:
|
||||
pulseSeries.daily.length >= 3
|
||||
? {
|
||||
summary: summarizeSeries(
|
||||
pulseSeries.daily.map((bucket) => ({
|
||||
value: bucket.value,
|
||||
})),
|
||||
),
|
||||
correlation: moodVsPulseCorrelation,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
meta: {
|
||||
payload_size_bytes: snapshotJson.length,
|
||||
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Content-hash gate (v1.16.8): when the snapshot is unchanged since the
|
||||
// last real assessment, refresh the cache timestamp and skip the LLM.
|
||||
const snapshotHash = hashInsightSnapshot(snapshot);
|
||||
const unchanged = await refreshUnchangedStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
snapshotHash,
|
||||
});
|
||||
if (unchanged) {
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: true,
|
||||
text: unchanged.text,
|
||||
cached: true,
|
||||
updatedAt: unchanged.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const previousContext = await getPreviousInsightContext(
|
||||
userId,
|
||||
"mood-status",
|
||||
locale,
|
||||
12,
|
||||
);
|
||||
const previousContextBlock = formatPreviousContextForPrompt(
|
||||
previousContext,
|
||||
locale,
|
||||
);
|
||||
|
||||
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
|
||||
// MOOD is mood-entry backed (not a MeasurementType), so the cross-metric
|
||||
// RELATIONS sub-block is sourced from the snapshot's own crossMetricContext
|
||||
// rather than the measurement discovery engine; relations stays empty here
|
||||
// and the variety / data-strength / repetition signals carry the rotation.
|
||||
const varietyLead = pickVarietyLead(userId, "mood", todayKey);
|
||||
const steadyRun = computeSteadyRun(moodGraded.weekly, moodGraded.monthly);
|
||||
const assessmentContextBlock = buildAssessmentContextBlock(
|
||||
{
|
||||
varietyLead,
|
||||
dataStrength: {
|
||||
points: moodSeries.daily.length,
|
||||
newestDaysAgo: newestEntryDaysAgo,
|
||||
},
|
||||
repeatCount: steadyRun,
|
||||
relations: [],
|
||||
},
|
||||
locale,
|
||||
);
|
||||
|
||||
// The provider call is deferred to the caller — the single-card path runs
|
||||
// ONE completion, the batch path folds this prompt into one shared call.
|
||||
// A timeout / error is a transient miss (fallback served, no assessment
|
||||
// persisted); `none` (no provider / no consent) serves the no-key text.
|
||||
return {
|
||||
phase: "pending",
|
||||
metric: "mood",
|
||||
userId,
|
||||
cacheAction,
|
||||
systemPrompt: getMoodSystemPrompt(locale),
|
||||
userPrompt: getMoodUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
assessmentContextBlock,
|
||||
// v1.28.40 — rotating opener hint, per (user, metric, day).
|
||||
openerArchetypeHint(`${userId}:mood:${todayKey}`, locale as Locale),
|
||||
),
|
||||
snapshotHash,
|
||||
// v1.12.7 — match the archetype cards' 0.45.
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyMoodStatusText(locale, moodSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
timeout: (reason): StatusCardResult =>
|
||||
returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyMoodStatusText(locale, moodSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
// Outbound safety screen. The only transform here used to be
|
||||
// whitespace-normalisation, so a dose-change imperative or a fabricated
|
||||
// clinical risk score persisted as the day's cached assessment. Policy
|
||||
// for a background-generated card is WITHHOLD: serve the deterministic
|
||||
// stub and persist no model text (see `finalizeStatusSummary`).
|
||||
const screened = finalizeStatusSummary(outcome.content, locale);
|
||||
if (!screened.ok) {
|
||||
annotate({
|
||||
action: { name: "insights.status.outbound_blocked" },
|
||||
meta: { cacheAction, reason: screened.reason },
|
||||
});
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: "screened",
|
||||
userId: userId,
|
||||
todayKey: todayKey,
|
||||
stubText: getNoKeyMoodStatusText(locale, moodSignal),
|
||||
});
|
||||
}
|
||||
const summary = screened.text;
|
||||
if (!summary) {
|
||||
throw new Error("Mood-status summary was empty after normalization");
|
||||
}
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
text: summary,
|
||||
snapshotHash,
|
||||
});
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt,
|
||||
};
|
||||
},
|
||||
weightSeries,
|
||||
sysSeries,
|
||||
pulseSeries,
|
||||
moodVsWeightCorrelation,
|
||||
moodVsSysCorrelation,
|
||||
moodVsPulseCorrelation,
|
||||
tagSummary,
|
||||
tagInfluence,
|
||||
betterDays,
|
||||
factorCrosstab,
|
||||
timeOfDay,
|
||||
stability,
|
||||
narratives,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMoodStatusLocale(
|
||||
locale: string | null | undefined,
|
||||
): SupportedLocale {
|
||||
return normalizeLocale(locale);
|
||||
/**
|
||||
* The cross-metric block: each of weight, systolic and pulse with at least
|
||||
* three daily points, summarised with its mood correlation.
|
||||
*/
|
||||
function moodCrossMetricContext(
|
||||
r: Awaited<ReturnType<typeof computeMoodRelations>>,
|
||||
) {
|
||||
return r.weightSeries.daily.length >= 3 ||
|
||||
r.sysSeries.daily.length >= 3 ||
|
||||
r.pulseSeries.daily.length >= 3
|
||||
? {
|
||||
weight:
|
||||
r.weightSeries.daily.length >= 3
|
||||
? {
|
||||
summary: summarizeSeries(
|
||||
r.weightSeries.daily.map((bucket) => ({
|
||||
value: bucket.value,
|
||||
})),
|
||||
),
|
||||
correlation: r.moodVsWeightCorrelation,
|
||||
}
|
||||
: null,
|
||||
bloodPressureSystolic:
|
||||
r.sysSeries.daily.length >= 3
|
||||
? {
|
||||
summary: summarizeSeries(
|
||||
r.sysSeries.daily.map((bucket) => ({
|
||||
value: bucket.value,
|
||||
})),
|
||||
),
|
||||
correlation: r.moodVsSysCorrelation,
|
||||
}
|
||||
: null,
|
||||
pulse:
|
||||
r.pulseSeries.daily.length >= 3
|
||||
? {
|
||||
summary: summarizeSeries(
|
||||
r.pulseSeries.daily.map((bucket) => ({
|
||||
value: bucket.value,
|
||||
})),
|
||||
),
|
||||
correlation: r.moodVsPulseCorrelation,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
const MOOD_STATUS_CARD: StatusCardPlugin<StatusTextResult, undefined> = {
|
||||
scope: "mood",
|
||||
codec: textStatusCodec,
|
||||
unavailableText: (locale) => getNoKeyMoodStatusText(locale),
|
||||
emptyMessage: "Mood-status summary was empty after normalization",
|
||||
build: buildMoodSnapshot,
|
||||
};
|
||||
Reference in new issue
Block a user