mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/end-medication-contributor-base
# Conflicts: # src/__tests__/baselines/source-file-lines.json # src/__tests__/baselines/source-function-lines.json
This commit is contained in:
commit
a35ff5f5ea
47 files changed
+2606
-447
No files matched your search
@@ -2035,6 +2035,9 @@
|
||||
"kind": "cron",
|
||||
"name": "coach-nudge",
|
||||
"options": {
|
||||
"retryBackoff": true,
|
||||
"retryDelay": 60,
|
||||
"retryLimit": 2,
|
||||
"tz": "Europe/Berlin"
|
||||
},
|
||||
"timezone": "Europe/Berlin"
|
||||
|
||||
@@ -1631,15 +1631,15 @@
|
||||
},
|
||||
{
|
||||
"dateKey": "2026-06-15",
|
||||
"generatedAt": "2026-06-15T10:00:00.000Z",
|
||||
"generatedAt": null,
|
||||
"inputHash": null,
|
||||
"items": null,
|
||||
"locale": "en",
|
||||
"metric": "mood",
|
||||
"negativeReason": null,
|
||||
"retryAt": null,
|
||||
"snapshotHash": "7dd495a6d92c3bc42320044bbbcbe89154afa13e0a954d9f62dee6239bced6f4",
|
||||
"text": "Parity note for batch-unchanged-en.",
|
||||
"negativeReason": "screened",
|
||||
"retryAt": "2026-06-15T10:05:00.000Z",
|
||||
"snapshotHash": null,
|
||||
"text": null,
|
||||
"userId": "parity-rich"
|
||||
},
|
||||
{
|
||||
@@ -1703,120 +1703,7 @@
|
||||
}
|
||||
},
|
||||
"batch-unchanged-en": {
|
||||
"calls": [
|
||||
{
|
||||
"maxTokens": 250,
|
||||
"messages": [
|
||||
{
|
||||
"content": [
|
||||
"Date: 2026-06-15",
|
||||
"OPENER HINT: Open with what this means for the user today, then the number behind it.",
|
||||
"Write one short assessment of this person's mood. Open with what the recent pattern MEANS in plain words — the read, not the number (e.g. \"a brighter stretch than usual\", \"steady, holding where it's been\") — then bring in the recent level right after as support, placed against their own weekly/monthly baseline; never lead with the value. Close with one kind, doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.",
|
||||
"",
|
||||
"PREVIOUS ANALYSIS: none on file. Treat this as the user's first analysis for this domain — no improvement/regression delta to surface.",
|
||||
"",
|
||||
"",
|
||||
"VARIETY: to keep your cards from reading the same, you may open this one by leading with the recent direction of travel (rising / falling / flat vs the baseline) — only if the data genuinely supports that angle; never force it, never invent a trend.",
|
||||
"",
|
||||
"DATA STRENGTH: 70 day-buckets in the window, latest reading today.",
|
||||
"",
|
||||
"",
|
||||
"{\"locale\":\"en\",\"generatedForDay\":\"2026-06-15\",\"focus\":\"mood\",\"dataCoverage\":{\"totalEntries\":70,\"totalSpanDays\":69,\"newestEntryDaysAgo\":0},\"mood\":{\"signal\":{\"metric\":\"your mood\",\"unit\":\"/5\",\"current\":3.15,\"currentWindowDays\":20,\"baseline\":3.24,\"baselineLabel\":\"your recent-weeks average\",\"delta\":-0.09,\"deltaPct\":-2.7,\"spread\":0.75,\"outsideNormalSwing\":false,\"direction\":\"higher-better\",\"n\":20,\"newestDaysAgo\":0,\"normalRange\":{\"low\":3.5,\"high\":5},\"placement\":\"below band\"},\"summary\":{\"points\":70,\"start\":1,\"end\":2,\"delta\":1,\"mean\":3.03,\"min\":1,\"max\":5},\"series\":{\"recent\":[{\"date\":\"2026-05-26\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-05-27\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-05-28\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-05-29\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-05-30\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-05-31\",\"min\":1,\"max\":1,\"mean\":1,\"n\":1},{\"date\":\"2026-06-01\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-06-02\",\"min\":1,\"max\":1,\"mean\":1,\"n\":1},{\"date\":\"2026-06-03\",\"min\":4,\"max\":4,\"mean\":4,\"n\":1},{\"date\":\"2026-06-04\",\"min\":1,\"max\":1,\"mean\":1,\"n\":1},{\"date\":\"2026-06-05\",\"min\":4,\"max\":4,\"mean\":4,\"n\":1},{\"date\":\"2026-06-06\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-06-07\",\"min\":4,\"max\":4,\"mean\":4,\"n\":1},{\"date\":\"2026-06-08\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-06-09\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-06-10\",\"min\":2,\"max\":2,\"mean\":2,\"n\":1},{\"date\":\"2026-06-11\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-06-12\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1},{\"date\":\"2026-06-13\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1},{\"date\":\"2026-06-14\",\"min\":3,\"max\":3,\"mean\":3,\"n\":1}],\"weekly\":[{\"weekISO\":\"2026-W15\",\"min\":2,\"max\":5,\"mean\":3.33,\"n\":6},{\"weekISO\":\"2026-W16\",\"min\":1,\"max\":5,\"mean\":2.86,\"n\":7},{\"weekISO\":\"2026-W17\",\"min\":1,\"max\":5,\"mean\":2.86,\"n\":7},{\"weekISO\":\"2026-W18\",\"min\":1,\"max\":5,\"mean\":3,\"n\":7},{\"weekISO\":\"2026-W19\",\"min\":1,\"max\":5,\"mean\":3.14,\"n\":7},{\"weekISO\":\"2026-W20\",\"min\":1,\"max\":5,\"mean\":3.14,\"n\":7},{\"weekISO\":\"2026-W21\",\"min\":1,\"max\":4,\"mean\":2.57,\"n\":7},{\"weekISO\":\"2026-W22\",\"min\":5,\"max\":5,\"mean\":5,\"n\":1}],\"monthly\":[],\"yearly\":[]},\"latestDayFocus\":{\"dayOffset\":0,\"value\":1,\"deltaToPreviousDailyPoint\":-2},\"target\":{\"greenMin\":3.5,\"greenMax\":5,\"orangeMin\":2,\"orangeMax\":3.5,\"inTargetPctLast30DailyPoints\":40},\"tags\":null,\"tagInfluence\":{\"flat\":null,\"structured\":[{\"tag\":\"alcohol\",\"labelKey\":\"mood.tag.alcohol\",\"label\":null,\"categoryKey\":\"health\",\"icon\":\"Wine\",\"withDays\":24,\"withoutDays\":46,\"withAvg\":2.96,\"withoutAvg\":3.07,\"delta\":-0.11,\"pValue\":0.7704186651066071,\"confidence\":\"low\"}]},\"betterDays\":[{\"source\":\"metric\",\"key\":\"weight\",\"labelKey\":null,\"label\":null,\"categoryKey\":null,\"icon\":null,\"direction\":\"up\",\"n\":35,\"confidence\":\"medium\",\"effectSize\":0.523,\"delta\":null,\"r\":0.523},{\"source\":\"tag\",\"key\":\"alcohol\",\"labelKey\":\"mood.tag.alcohol\",\"label\":null,\"categoryKey\":\"health\",\"icon\":\"Wine\",\"direction\":\"down\",\"n\":24,\"confidence\":\"low\",\"effectSize\":0.07670850767085077,\"delta\":-0.11,\"r\":null}],\"factorCrosstab\":null,\"narratives\":[{\"kind\":\"trend\",\"messageKey\":\"insights.mood.narrative.trendUp\",\"vars\":{\"direction\":\"up\",\"delta\":\"0.9\",\"days\":\"7\"},\"strength\":0.86},{\"kind\":\"weekday-dip\",\"messageKey\":\"insights.mood.narrative.weekdayDip\",\"vars\":{\"weekdayKey\":\"charts.weekdaysFull.mon\",\"delta\":\"0.3\"},\"strength\":0.33},{\"kind\":\"in-target\",\"messageKey\":\"insights.mood.narrative.inTarget\",\"vars\":{\"pct\":\"40\"},\"strength\":0.2},{\"kind\":\"streak\",\"messageKey\":\"insights.mood.narrative.streak\",\"vars\":{\"days\":\"70\"},\"strength\":0.15}],\"timeOfDay\":{\"buckets\":[{\"bucket\":\"morning\",\"avgScore\":2.91,\"count\":35},{\"bucket\":\"afternoon\",\"avgScore\":null,\"count\":0},{\"bucket\":\"evening\",\"avgScore\":null,\"count\":0},{\"bucket\":\"night\",\"avgScore\":3.14,\"count\":35}],\"best\":\"night\",\"worst\":\"morning\"},\"stability\":{\"score\":6,\"stdDev\":1.414,\"band\":\"veryVariable\",\"days\":70}},\"crossMetricContext\":{\"weight\":{\"summary\":{\"points\":70,\"start\":84.2,\"end\":82.38,\"delta\":-1.82,\"mean\":83.19,\"min\":82.03,\"max\":84.48},\"correlation\":{\"r\":0.523,\"strength\":\"moderat\",\"n\":35}},\"bloodPressureSystolic\":{\"summary\":{\"points\":94,\"start\":128,\"end\":125,\"delta\":-3,\"mean\":125.39,\"min\":116,\"max\":134},\"correlation\":null},\"pulse\":{\"summary\":{\"points\":140,\"start\":70,\"end\":72,\"delta\":2,\"mLine truncated
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"responseFormat": "json",
|
||||
"seed": 1618033,
|
||||
"system": [
|
||||
"You are writing a short, personal assessment of ONE of this user's health metrics. You know their data and you read it against their OWN baseline — never against a population norm. Your clinical grounding is established guidelines (ESH 2023, WHO, AASM, DGE, DEGAM); you reference them without diagnosing.",
|
||||
"",
|
||||
"YOUR DATA (graded snapshot):",
|
||||
"- signal: the finished comparison — { metric, unit, current, currentWindowDays, baseline, baselineLabel, delta, deltaPct, spread, outsideNormalSwing, direction, normalRange?, placement?, n, newestDaysAgo, contributors? }. `current` is the recent-window mean; `baseline` is the user's OWN longer average; `delta`/`deltaPct` are the SIGNED change vs that baseline; `spread` is their normal night-to-night swing; `outsideNormalSwing` is the pre-computed verdict on whether the change exceeds that swing. These are already computed — state them, do NOT recompute them. `contributors[]` (composite scores only) names the 1–2 sub-scores that moved the score.",
|
||||
"- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.",
|
||||
"- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).",
|
||||
"- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.",
|
||||
"- series.monthly[]: the ~12 months before that, one calendar-month aggregate each.",
|
||||
"- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).",
|
||||
"- So the most recent days are present individually; older spans are folded into week, month and year aggregates. Consult the series only for a specific day; lead from the signal block.",
|
||||
"- A slice can be empty when the history is short — do not pretend it exists. When `signal.baseline` is null there is no established baseline yet; say so honestly instead of inventing a comparison.",
|
||||
"",
|
||||
"HOW TO BUILD THE ASSESSMENT — pick the 2–3 MOVES that fit THIS data and order them so they read as one connected thought, not a fixed checklist. Lead per the OPENER HINT in the user prompt when one is given — do NOT always open with the number. Vary the shape from one metric to the next; never open two metrics in a row the same way.",
|
||||
"Moves available (choose the few that genuinely apply):",
|
||||
"(a) the current finding — the signal block's `current` (or a value from series.recent for a day-level question);",
|
||||
"(b) place it against the user's OWN baseline via the pre-computed signed `delta` vs `baselineLabel` — state it, do not re-derive it. Treat `outsideNormalSwing: false` as \"inside your usual range — not a finding\" and do NOT manufacture a trend; `outsideNormalSwing: true` is the real, reportable change. A `normalRange`/`placement` is only a coarse secondary anchor — the personal delta leads;",
|
||||
"(c) name a genuine, earned win or a steady streak when the data shows one;",
|
||||
"(d) one watch-item worth keeping an eye on;",
|
||||
"(e) an association ONLY when an r-value is present and |r| > 0.4, phrased as \"moves with\" / \"tends to show up alongside\", never causal;",
|
||||
"(f) EXACTLY ONE doable next step — and only when the finding genuinely implies one. One message = one behaviour, no list. When the value is steady and in a good place, do NOT manufacture a step — affirm briefly and name one thing to watch instead. A fabricated step is exactly the platitude we ban.",
|
||||
"Synthesize, don't recite: the story of what the data means matters more than re-stating every number.",
|
||||
"",
|
||||
"TONE — a warm, motivating premium advisor (someone really looked at this person's data and found something worth their attention):",
|
||||
"- Second person (\"your blood pressure\", \"your values\"), warm, direct, honest.",
|
||||
"- Motivating and forward-looking: when the data earns it, name the genuine win plainly and build a little momentum — make the person feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment.",
|
||||
"- Autonomy-supporting: \"can help\", \"worth a try\", never \"you must\".",
|
||||
"- Never alarming, never moralising, never diagnostic — make no disease claim. Frame as a reasoned observation, not medical advice.",
|
||||
"- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step, framed as an opportunity rather than a failing. Do not downplay, do not dramatise.",
|
||||
"- No platitudes and no bare number-echoing: every warm line is anchored to a real figure or a real change. A generic positivity opener (\"Your numbers look good\") is banned — earn the encouragement with the specific finding.",
|
||||
"",
|
||||
"OPENING — lead with meaning, not the metric value",
|
||||
"Open on the read in plain words — what this says about the person today — and bring the number in right after, as support, never as the first thing. A line that opens on a value (\"Your resting heart rate is 61 …\") reads as a readout; the same line that opens on the meaning (\"Your heart's running a little calmer this week — about 61 …\") reads as an advisor who actually looked. Keep every claim tied to a real figure; only the ORDER changes, meaning first and the number right behind it. This holds whether the surface covers one metric or many.",
|
||||
"",
|
||||
"LENGTH: 2-4 sentences, roughly 30-60 words — but when the metric is steady and there is nothing to act on, ONE tight sentence is better than padding it to length. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.",
|
||||
"",
|
||||
"JUDGE THE DATA HONESTLY (never invent a trend):",
|
||||
"- Only a few measurement points/entries/events or an empty recent slice: say honestly that there is too little data for a reliable trend yet, and give ONE pointer (e.g. log more regularly). Do not claim a trend. What \"few\" means depends on the metric — for mood it is entries, for adherence it is scheduled doses.",
|
||||
"- Newest measurement clearly older than ~7 days (dataCoverage.newestMeasurementDaysAgo): note that the values may be out of date.",
|
||||
"- Mention correlations ONLY when the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent one. Always phrase as an \"association\", never a \"cause\".",
|
||||
"",
|
||||
"FORBIDDEN FILLER (signals ungrounded platitudes — never emit, except in a disclaimer)",
|
||||
"\"make sure to get enough sleep\", \"drink enough water\", \"regular exercise\", \"consult your doctor\".",
|
||||
"",
|
||||
"METRIC IDENTIFIERS STAY OUT OF PROSE",
|
||||
"Never write database / enum-style names like \"BLOOD_PRESSURE_SYS\", \"PULSE_BPM\", \"MOOD_SCORE\", \"MEDICATION_COMPLIANCE_PCT\", \"HEART_RATE_VARIABILITY\", \"RESTING_HEART_RATE\", \"ACTIVE_ENERGY_BURNED\", \"FLIGHTS_CLIMBED\", \"WALKING_RUNNING_DISTANCE\", \"VO2_MAX\", \"BODY_TEMPERATURE\" or \"SLEEP_DURATION\" in any user-facing string, and never write the literal \"metric:<TYPE>\" chart-token in prose. Reference each metric with the natural-language label the user sees — \"your systolic\", \"your weight\", \"your pulse\", \"your mood\", \"your medication adherence\", \"your resting heart rate\", \"your sleep duration\", \"your steps\".",
|
||||
"",
|
||||
"GLP-1 DOSE SAFETY (contract, not style)",
|
||||
"NEVER prescribe, recommend, or modify medication doses, even when the snapshot names a GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). You may NOTE the named medication and the current titration step (\"week 3 on 7.5 mg\") when the snapshot carries it, but never write \"step up to X mg\", \"consider increasing to Y mg\", \"stop at Z mg\", or any variation. A plateau always frames the next decision as a conversation with the prescribing clinician. If you are unsure whether something is dose-prescriptive, treat it as if it is and defer.",
|
||||
"",
|
||||
"ACUTE RED FLAGS (contract, not style)",
|
||||
"If the user describes an acute crisis sign — chest pain or chest pressure, fainting or near-fainting (syncope), a sudden severe symptom (e.g. worst-ever headache, sudden weakness or trouble speaking, trouble breathing), a hypertensive-crisis reading (systolic ≥ 180 or diastolic ≥ 120 with symptoms), a severe-low or very-high glucose reading (below 54 mg/dL (3.0 mmol/L), or below 70 mg/dL (3.9 mmol/L) with symptoms, or at/above 250 mg/dL (13.9 mmol/L) with symptoms), a sustained fever at/above 38.5 °C (101.3 °F), or any mention of suicidal thoughts or self-harm — say plainly, in one calm sentence, that this needs prompt medical attention or emergency services now, and do NOT data-coach it. Do not diagnose, do not name a condition, do not estimate severity from the numbers — just point to prompt/emergency care and stop. This is a closed list; outside it, stay calm and non-alarmist as usual.",
|
||||
"",
|
||||
"GROUNDING — ZERO HALLUCINATIONS",
|
||||
"Every claim must trace to a number visible in the snapshot you were given. If you cannot point to a snapshot field, do NOT make the claim, and never invent a measurement to satisfy a request. Read each value against the user's OWN baseline (their recent vs longer-window averages), never against a population norm. State a correlation only when its r-value is present and |r| > 0.4, always as an \"association\", never a \"cause\".",
|
||||
"",
|
||||
"TONE — a warm, motivating advisor, never clinical or alarming",
|
||||
"Write in the second person, warm and direct. When the data earns it, name the genuine win plainly and build a little momentum — the person should feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment. Affirmation is anchored, specific, and proportionate: tie it to a real figure or change, make it non-transferable to another user, and keep credit quiet for quiet wins. Over-validation is a safety regression, not warmth — never affirm a worsening trend or an unsafe choice; validate the effort, not the choice, and when there is nothing to praise, stay neutral (\"nothing to act on — the good kind of boring\") rather than manufacturing a compliment. Be autonomy-supporting (\"worth a try\", \"can help\", never \"you must\"). Name unfavourable values honestly too — finding, then place it against the user's own baseline, then one small doable step framed as an opportunity. Never alarm, never moralise, never diagnose. No platitudes and no bare number-echoing. Do NOT open with a compliment about the data quantity or quality; banned openers include \"Your data foundation is strong\", \"Datengrundlage ist sehr stark\", \"You have a solid baseline\", \"Great dataset\", a generic \"Your numbers look good\", and any rephrasing of the same sentiment.",
|
||||
"",
|
||||
"NOT AN INSIGHT — never recite mechanics",
|
||||
"A measurement count (\"logged 3 times\"), a logging cadence (\"measured every few days\"), or page-mechanics narration is NOT an insight and does not belong in the prose on its own. Use such a fact ONLY when it carries a consequence, and then state the consequence, not the count: e.g. \"too few readings this week to call a trend — one morning reading would settle it\", not \"you measured twice this week\". Every sentence must earn its place by telling the person what a value MEANS or what follows from it, never by narrating how the data was collected.",
|
||||
"",
|
||||
"INTERPRET, DON'T ENUMERATE",
|
||||
"Say what a value MEANS, not just what it is. When the user prompt carries an INTERPRETATION CONTEXT block, use it: name where the value sits on the guideline scale, what that band means in plain, dignified words, and what practically follows. Judge the trend IN CONTEXT — a \"slightly rising\" reading deep inside a favourable band is a footnote; the same movement at or near a boundary is the headline. Frame any consequence WITHOUT diagnosis: \"values in this range are considered … per guidelines\", never \"you have X\" or \"you are at risk of X\". When NO interpretation block is present, the metric has no general reference band — interpret it relative to the person's OWN baseline and say plainly that no general reference band is attached, rather than inventing one.",
|
||||
"",
|
||||
"FORMATTING — write in short paragraphs separated by a BLANK LINE, each 1–3 sentences; a longer reply is 2–4 paragraphs, never one block. Put a blank line between distinct ideas (e.g. \"where things stand\" vs \"one thing to try\"). A steady one-liner stays a single paragraph — do NOT pad to fill a second. When you genuinely enumerate three or more parallel items (options, findings, steps), format them as a plain list: one item per line, each line starting with \"- \". You may bold the single most important takeaway with **double asterisks**, at most once per reply. Nothing else: no headings, no italics, no backticks, no numbered lists, no emojis.",
|
||||
"",
|
||||
"EXAMPLES — note the DIFFERENT shapes (verdict-led, trend-led, one-liner). They illustrate form and grounding only; never copy them — every assessment uses the real snapshot numbers:",
|
||||
"- VERDICT-LED (meaning first, number as support, one step): \"Your resting heart rate is running a touch lower than usual — about 61 bpm this week, 5 below your monthly mean of 66, and your lowest in weeks. That kind of dip usually tracks with more movement; worth keeping the routine that earned it.\"",
|
||||
"- TREND-LED (direction first, then where it stands): \"Your systolic pressure has been easing down steadily for three weeks now, sitting around 124 mmHg — about 4 under your 30-day average. Nothing dramatic, just a consistent direction.\"",
|
||||
"- ONE-LINER (steady, NO forced step): \"Your SpO₂ is steady at 97 %, right inside your usual range — nothing to act on, the good kind of boring.\"",
|
||||
"- BAD (banned filler, ungrounded — do NOT write this): \"Your numbers look good. Make sure to get enough sleep and keep up regular exercise.\"",
|
||||
"",
|
||||
"OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The \"summary\" field holds the complete assessment in English: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:",
|
||||
"{ \"summary\": \"...\" }",
|
||||
"",
|
||||
"METRIC — MOOD / WELL-BEING:",
|
||||
"- Scale 1 (very bad) to 5 (very good). The snapshot carries mood.signal (the finished comparison) + mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags).",
|
||||
"- The weekly pattern matters: lead from mood.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Treat outsideNormalSwing: false as \"inside the usual swing — not a finding\".",
|
||||
"- Name persistent phases seriously but calmly: several days below ~2.5 or above ~4.5 are worth noting — as an observation, never a diagnosis and never alarming.",
|
||||
"- When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. \"if the strain persists, talking it through with someone you trust or a professional can help\". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative.",
|
||||
"- Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link.",
|
||||
"- Cross-metric: crossMetricContext carries correlations to weight, blood pressure and pulse; mention only when present and |r| > 0.4 — as an association, never a cause. Do not force a cross-link without a clear pattern.",
|
||||
"- One message: close with ONE doable, kind step ONLY when the finding implies one (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step."
|
||||
],
|
||||
"temperature": 0.45,
|
||||
"timeoutMs": 60000
|
||||
}
|
||||
],
|
||||
"calls": [],
|
||||
"enqueued": [],
|
||||
"notes": [
|
||||
{
|
||||
@@ -1882,15 +1769,15 @@
|
||||
},
|
||||
{
|
||||
"dateKey": "2026-06-15",
|
||||
"generatedAt": "2026-06-15T10:00:00.000Z",
|
||||
"generatedAt": null,
|
||||
"inputHash": null,
|
||||
"items": null,
|
||||
"locale": "en",
|
||||
"metric": "mood",
|
||||
"negativeReason": null,
|
||||
"retryAt": null,
|
||||
"snapshotHash": "7dd495a6d92c3bc42320044bbbcbe89154afa13e0a954d9f62dee6239bced6f4",
|
||||
"text": "Parity note for batch-unchanged-en.",
|
||||
"negativeReason": "screened",
|
||||
"retryAt": "2026-06-15T10:05:00.000Z",
|
||||
"snapshotHash": null,
|
||||
"text": null,
|
||||
"userId": "parity-rich"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
* sticky-stub bug used to break.
|
||||
*
|
||||
* 2. A negative-cache window opened by an earlier stall never stands in
|
||||
* for a note: with no note stored for today, the generator regenerates
|
||||
* and stores the real text, and writing it closes the window.
|
||||
* for a note: once it lapses, with no note stored for today, the
|
||||
* generator regenerates and stores the real text, and writing it closes
|
||||
* the window and resets the failure streak. While it is open (#1279),
|
||||
* no provider is asked and the window stays as it was.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -117,19 +119,20 @@ describe("status path delivers real provider text", () => {
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("regenerates past a negative-cache window and closes it", async () => {
|
||||
it("regenerates past a lapsed negative-cache window and closes it", async () => {
|
||||
const user = await seedRichWeightUser("status-stub-skip-user");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// An earlier stall opened a window, with no note stored.
|
||||
// Earlier stalls opened a window, now lapsed, with no note stored.
|
||||
await prisma.insightStatusCache.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
metric: "weight",
|
||||
locale: "en",
|
||||
dateKey: "2026-01-01",
|
||||
retryAt: new Date(Date.now() + 60_000),
|
||||
retryAt: new Date(Date.now() - 1_000),
|
||||
negativeReason: "timeout",
|
||||
failureStreak: 3,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -153,5 +156,49 @@ describe("status path delivers real provider text", () => {
|
||||
});
|
||||
expect(stored.retryAt).toBeNull();
|
||||
expect(stored.negativeReason).toBeNull();
|
||||
expect(stored.failureStreak).toBe(0);
|
||||
});
|
||||
|
||||
it("asks no provider while the window is open (#1279)", async () => {
|
||||
const user = await seedRichWeightUser("status-cooling-user");
|
||||
const prisma = getPrismaClient();
|
||||
const retryAt = new Date(Date.now() + 60_000);
|
||||
await prisma.insightStatusCache.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
metric: "weight",
|
||||
locale: "en",
|
||||
dateKey: "2026-01-01",
|
||||
retryAt,
|
||||
negativeReason: "error",
|
||||
failureStreak: 2,
|
||||
},
|
||||
});
|
||||
const { resolveProviderChain } = await import("@/lib/ai/provider");
|
||||
const [entry] = await vi.mocked(resolveProviderChain)(user.id);
|
||||
const generate = vi.mocked(entry.instance.generateCompletion);
|
||||
generate.mockClear();
|
||||
|
||||
const { generateWeightStatusForUser } =
|
||||
await import("@/lib/insights/weight-status");
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(result.text).not.toBe(KNOWN_TEXT);
|
||||
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
|
||||
where: {
|
||||
userId_metric_locale: {
|
||||
userId: user.id,
|
||||
metric: "weight",
|
||||
locale: "en",
|
||||
},
|
||||
},
|
||||
});
|
||||
// The window neither moved nor counted the refused generation.
|
||||
expect(stored.retryAt?.getTime()).toBe(retryAt.getTime());
|
||||
expect(stored.failureStreak).toBe(2);
|
||||
expect(stored.textEncrypted).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -85,9 +85,11 @@ vi.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/insights/status-cache", async (importOriginal) => {
|
||||
vi.mock("@/lib/insights/status-negative-window", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/status-cache")>();
|
||||
await importOriginal<
|
||||
typeof import("@/lib/insights/status-negative-window")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
writeStatusNegativeWindow: (
|
||||
|
||||
Reference in new issue
Block a user