Commit Graph
104 Commits
Author SHA1 Message Date
Marc-André Bombeck f987efd9f3 refactor(insights): move the insights reply-language footers into their own module
The per-locale footer table is prompt text, not prompt logic; it moves beside the generator, which drops back under its size baseline after pt-BR joined the table.
2026-10-22 15:19:33 +02:00
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00
Marc-André Bombeck 6182d45354 fix(i18n): print server-built sentences in the reader's notation
The decimal lint covered components and app only, and sentences for
people are built in src/lib too: the weekday weight correlation printed
"0.8 kg" into a German sentence, and the no-key fallback and the period
narrative's fallback spelled fractions with a full stop. Those three use
the new formatDecimal from format-locale with the reader's locale. The
rule now covers src/lib and src/hooks; text for the model, operator log
lines, SVG data, zone checks and wire values a client re-formats carry a
reasoned directive.
2026-10-20 16:52:08 +02:00
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00
Rob de Graaf c6f61fad33 feat(i18n): add Dutch as an eighth end-to-end locale
Adds messages/nl.json (AI-initial, informal je/jij register, full key
parity with en.json) and registers nl everywhere the locale list lives:
config, loaders, Intl/date-fns mapping, zod enums, OpenAPI spec, scripts,
the managed-profile picker and the notification locale guards.

Dutch follows the Korean pattern for the AI surfaces (English body plus a
Dutch reply-language footer, no safety-contract matrix) and gets its own
banks in the outbound safety screen (dose, risk, causal, continuation),
the prose-grounding and citation-coverage tables, the reference-range
prose and the no-key fallback copy, each with Dutch test fixtures.

PHQ-9 / GAD-7 / WHO-5 are not marked validated for nl yet: the Dutch
wording has not been read against the published forms, so Dutch readers
get the English items plus the existing note until it has.
2026-10-19 11:06:55 +02:00
Marc-André Bombeck 072c1e9394 Compare glucose signals by the hour and date stale strip readings
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.

The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
2026-10-06 20:53:39 +02:00
Marc-André Bombeck aa222f5b02 fix(daily): speak about today only from today's readings
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.

- computeSignalsOfDay drops a metric with no reading today, so the
  briefing prompt and its content hash never see a stale value as the
  day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
  drops a reading-backed signal whose metric was not measured today,
  so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
  coincident flag and the health status deviations use the new
  isFromToday rule (calendar day, not elapsed hours).

Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
2026-10-05 21:22:57 +02:00
Marc-André Bombeck 7cb1b5ba05 fix(insights): write the weight note and its thresholds in the reader's mass unit
The weight status note had its own generator and never took the unit
preference: its signal, summary, graded series, latest-day figure,
target band and correlation pairs were all in kilograms, and the card
prompt stated the normal day-to-day swing, the plateau line and the
loss pace in kilograms too. A reader on pounds was handed both units.

The generator now converts every weight figure once after the canonical
arithmetic (the correlations are scale-free and stay as computed), the
card prompt states its thresholds in the reader's unit, and the unit
preference joins the input fingerprint so a switch is never answered
with the old note. The shared status-card examples and the briefing's
plateau pattern no longer show a weight in kilograms for the model to
copy.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck ca5ce518d8 fix(coach): state the snapshot's weight, body and distance figures in the reader's units
The Coach snapshot carried weight, body composition, temperatures and
walking speed and distance as stored: kilograms, degrees Celsius, metres
per second and metres, with no unit on the block. The evidence-block
rules then listed kg and both glucose units as the units to cite. A
reader on pounds, Fahrenheit or miles was answered in the units they do
not use.

Every value block now converts its timeline (and the weight block its
aggregate and coarse tail) into the reader's units after aggregating on
the canonical value, and names the unit on the block; the grounding
scalars stay canonical. Workout distances arrive in kilometres or miles
with the unit named once. The prompt in every locale tells the model
that a block's figures are already in the reader's unit and takes the
evidence-block unit from the block instead of a fixed list.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 6dbf57f4f4 fix(ai): name the acute glucose and fever floors in both units
The acute red-flag clause stated the glucose floors only in mg/dL and the
sustained-fever floor only in °C. The clause is matched against what the
person writes, so a reading of 2.9 mmol/L or 103 °F did not meet any of
its numbers. Each floor now carries both units, mg/dL and °C first, in
the English and German contracts.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 6c6d363bba fix(insights): write metric status notes in the reader's units
The per-metric status note quoted glucose in mg/dL, body composition in
kg, temperatures in °C and walking distance in metres, whatever the
reader had chosen; the deterministic floor without a provider did the
same. The generator now converts the series, the reference band and the
signal into the reader's units before they reach the prompt or the
floor, and prints the guideline band table in that unit while still
classifying on the canonical value.

A changed glucose unit or metric/imperial preference (own account or a
managed record) now re-warms the stored notes and narratives through
the hash-gated refill, so today's text does not keep the old unit.

Refs #1067
2026-10-01 08:52:44 +02:00
Marc-André Bombeck 9d2fd1db14 fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
2026-09-26 16:23:14 +02:00
Marc-André Bombeck 8f889cf942 feat(weight): give the weight status note the stored target
The weight status note's snapshot now carries the person's own target
band and which way is progress, and its comparison signal is framed as
higher-better below the band and lower-better above it instead of the
neutral target-band framing. The weight prompt tells the model to judge
changes against that target and keeps the loss pace and milestone
guidance for when losing is the goal. The target is folded into the
input fingerprint, so editing it regenerates the note even when no
weight reading moved.

Refs #1006
2026-09-24 09:24:25 +02:00
aucun6352 f7cc47850a fix(i18n): make the Korean locale actually work end to end
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.

Three failures were not missing keys but wrong assumptions:

- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
  accents become plain letters, but Hangul decomposes into jamo, which
  are not in that range and survived — so folded Korean text was a jamo
  sequence no precomposed table entry could ever match. It recomposes to
  NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
  name to the empty string, so the derived index held none of them and a
  Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
  tilde a Korean lab actually prints (`3.5~5.0`).

The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.

The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
2026-08-26 20:00:44 +09:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
2026-08-26 16:42:45 +09:00
Marc-André Bombeck 4591899657 fix(release): harden v1.31 arrival contracts 2026-07-20 00:40:20 +02:00
Marc-André Bombeck 29673c1155 fix(insights): register the arrival reaction line with the tone harness
The harness landed in v1.30.35, after the slices that built this
release were already cut — so the reaction line's prompt module was
never registered, and its own docblock's warning against the
two-locale collapse was untested. Registering it surfaced a real gap:
the meaning-first instruction lived only in the system prompt's own
words, never in the user prompt in the family's shared phrasing, which
is where the rest of the surfaces carry it and where the tone contract
checks for it. The German body carried no such instruction at all.

Threads locale through the user-prompt builder so the fixed
instruction line matches the reader's own reply language, and wires
the per-user per-day opener-archetype rotation the milestone spec
called for and the first implementation had not connected.
2026-07-19 15:48:57 +02:00
Marc-André Bombeck 430cbb2ceb Merge remote-tracking branch 'origin/work/v131-slice3-activity' into release/v1.31.0
# Conflicts:
#	src/lib/jobs/reminder/register-status.ts
2026-07-19 15:42:11 +02:00
Marc-André Bombeck c1f583b114 Merge remote-tracking branch 'origin/work/v131-slice2-today' into release/v1.31.0 2026-07-19 15:40:50 +02:00
Marc-André Bombeck 1a469301b3 feat(workouts): generate the per-workout insight on arrival, never on view
The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.

The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
2026-07-19 13:56:50 +02:00
Marc-André Bombeck ca170925dc feat(daily): react in place when new data lands
Today gains two additive-nullable digest fields. `justIn` names the day's
newest arrival while it is still news — under three hours old, after which the
record is simply current rather than new. `reactionLine` carries a single
generated sentence about that arrival and replaces the hero's standing lead for
the rest of the local day, never appending a second paragraph, so the hero stays
one lead line tall and nothing below it shifts.

Nothing here polls. The open dashboard picks the change up on the cadence it
already runs — a state change, not a notification.

The generated sentence is bounded before it is written: the arrival spine's
unique row per kind per local day IS the throttle, the worker re-checks the
same claim before spending, and the reservation is reconciled on every exit
including each failure path. Without a provider, without consent, or against an
exhausted budget the row stays line-less and the surface falls back to the
deterministic lead — the chip and the provisional-to-final flip are unaffected.

The chip's time string is formatted client-side after mount; `justIn.at`
crosses the wire as an ISO instant so the server never renders a local
wall-clock time the browser would disagree with.
2026-07-19 13:56:21 +02:00
Marc-André Bombeck e53a8ce8a0 feat(workouts): add the activity-insight evidence projection and prompt
The evidence block is a numbers-only server projection: zone seconds, the
workout-window heart-rate shape, route climb and a same-sport 90-day own-history
median. Free-text metadata never enters it, and the sport string is narrowed
against the closed enum first, so the payload carries no text the user did not
generate through a measured field.

The prompt composes the shared assessment base and adds the session contract:
device-attributed, own-history-comparative, effort acknowledged where the
numbers show it, and no training prescriptions of any kind. Registered in the
tone harness beside the assessment cards.
2026-07-19 13:50:54 +02:00
Marc-André Bombeck 80e88cfb23 fix(insights): warm the deterministic fallbacks and re-fingerprint the cards
The no-signal floors were the coldest text the product renders. They run when
there is nothing to ground against, and they opened on a bare order — "Measure
blood pressure at rest and under comparable conditions", "Evaluate mood trends
over several weeks" — so the one moment the surface has nothing to say was also
the one moment it told the reader what to do.

They now open on what the surface can honestly say: no assessment is available
right now. That is the meaning-first opening for this case, and it is the only
one available, because a floor with no signal may not imply a read. What
follows describes how the measure behaves rather than ordering the reader
around. The signal-grounded lines above them are unchanged.

The prompt version bump re-fingerprints the assessments cached under the old
biomarker prompt; the per-card input hash folds the constant in, so the
regeneration is one-time and paced by the nightly warm.
2026-07-19 13:25:32 +02:00
Marc-André Bombeck ed049661b7 feat(insights): bring the biomarker card onto the shared assessment contract
The per-biomarker assessment was the last surface in the family building its
prompt inline: a five-line scaffold that skipped the base system prompt, took
no opener hint, and instructed the model to "state the current value" — so the
one card where a bare number reads most like a verdict was value-first by
instruction while every sibling had moved to meaning-first.

It now composes exactly as the seven bespoke cards do. The new prompt module
carries a METRIC section that states the one thing this card needs and the
others do not: its snapshot has no signal block, so the read comes from the
pre-computed range placement plus the direction across draws.

Also point both archetype-driven cards at a registered budget. The generic
metric card and the biomarker card each carried temperature/maxTokens inline
at 0.45/1000, so the registry that documents every surface's ceiling was
silently wrong about the two widest assessment surfaces.

The single-marker no-key fallback replaces the multi-metric overview tip the
card used to render — text about watching several metrics at once, on a card
showing one lab value.
2026-07-19 13:14:23 +02:00
Marc-André Bombeck 4183202ed3 Delete the citation-enforcement chain that never ran
generateInsight() parsed against the strict aiInsightResponseSchema,
cross-checked that every recommendation's metricSource appeared in
citations[], and threw InsightSchemaError on a second failure. Its only
caller was runWithFallback(), which had no production caller — every
live surface runs runRawCompletionWithFallback. So none of it executed.

Wiring it into the live path was the alternative and it is not viable:
the live insightResultSchema has no citations[] field at all and treats
metricSource as optional, so the cross-check would have rejected
essentially every generation rather than tightening anything.

Removed: runWithFallback and its param/result types, generateInsight
and its private helpers, findUncitedRecommendations,
findRecommendationsMissingRationale, the never-thrown InsightSchemaError
and the isHardProviderFailure branch that excluded it, and
computeConfidence — a deterministic override that only ran inside the
dead wrapper.

Kept: buildRetryCorrectionMessage, which comprehensive-generate sends on
its own retry leg; the file it lived in is renamed to match what it now
holds. aiInsightResponseSchema stays as the shape that message
describes. Note the retry prompt asks for a stricter payload than the
live validator requires — that mismatch predates this and is left alone.

computeCitationCoverage is untouched and still live: it is a metric, not
a gate, and v1.30.24 wired it into the comprehensive post-parse step.

Chain-runner coverage the deleted tests carried (last-working cache,
empty-input guard) is ported onto runRawCompletionWithFallback, which
shares the machinery.
2026-07-19 05:50:57 +02:00
Marc-André Bombeck d0a74e37da fix(ai): keep the output-language directive terminal on composed prompts
getBaseSystemPrompt appended the directive, then each metric module
appended its own section after it, so the directive was no longer the
last instruction the model reads - the one property that makes it
authoritative. The base now exposes its body, and every composing module
wraps the finished prompt so the directive ends it.

Also drops a duplicate import that survived the merge of the two prompt
branches.
2026-07-18 23:54:09 +02:00
Marc-André Bombeck decc6681f6 test(ai): pin the output-language adoption across the prompt family
Holds every sibling prompt module to one invariant: strip the directive
and the interpolated language name out of a French, Spanish, Italian or
Polish prompt and what remains is the English prompt, byte for byte. A
German instruction body leaking back into a non-German prompt, an
English body drifting away from what the four locales receive, and a
module appending a second directive on top of the one the base prompt
already carries all fail here.

Also drops the now-unused narrow locale alias from the two modules whose
prompt builders take the full locale.
2026-07-18 23:29:25 +02:00
Marc-André Bombeck 13197c7ee9 fix(ai): route the score, biomarker and coach prompts by instruction locale
The derived-score archetype and the Coach preference overrides branched
on the raw locale, so fr/es/it/pl composed German instruction text. Both
now route through `instructionLocale`. The score archetype rides on
`getBaseSystemPrompt` and therefore appends no directive of its own —
the base prompt already carries it.

The biomarker assessment is the one prompt in the family that does not
compose the base scaffold, so it appends the reader's directive itself;
the append collapses into the shared path once that prompt moves onto
the base scaffold.

The two prompt builders in each of those modules now take the full
`Locale` rather than the narrowed de/en alias, so the routing is right
the moment the pipeline stops collapsing the four locales on the way in.

Also targets the output-language contract test's regex at the compile
target instead of the dotAll flag, which does not compile.

German and English prompts are unchanged, byte for byte.
2026-07-18 23:22:15 +02:00
Marc-André Bombeck fff84ea84f fix(ai): route the metric assessment prompts by instruction locale
The per-metric system and user prompt builders branched
`locale === "en" ? EN : DE`, so a French, Spanish, Italian or Polish
reader composed the German instruction body — whose output clause asks
for German prose. Route them through `instructionLocale` instead, so
German is the only locale that takes the German body and everyone else
takes the English one plus the reader's own output-language directive
already carried by the base prompt.

The German and English prompts are unchanged, byte for byte.
2026-07-18 23:18:58 +02:00
Marc-André Bombeck 2032b74fc6 test(ai): pin the output-language contract across all six locales
The parity test joined raw fragments, which no longer matches now that
the English output clause carries a language token; it substitutes the
expected name from a literal map instead of from the module under test.

Adds the contract test: which body each locale composes, that its own
language is named, that its own translated directive is present and
last, that no German reaches a non-German prompt, and that no two
locales collapse to the same prompt. What it cannot show - whether the
prose reads well in the four languages - is stated in the file.
2026-07-18 23:14:45 +02:00
Marc-André Bombeck ec4e3adfdc fix(ai): write assessments in the reader's language, not German
getBaseSystemPrompt collapsed every non-English locale to the German
instruction body, whose output clause asks for a German assessment. A
French, Spanish, Italian or Polish reader therefore received German
prose on any surface that reached this prompt.

Those four locales now compose the English instruction body with their
language named in the output clause, and the locale's own reply-language
directive - already carried in production by the native insights and
briefing prompts - appended last. German and English compose exactly as
before, byte for byte.

Adds the shared output-language helper the remaining prompt modules
adopt, replacing the scattered en-or-else-German binaries.
2026-07-18 23:13:34 +02:00
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00
Marc-André Bombeck 461c1cfe09 chore(release): v1.28.40 — insight assessments lead with meaning, not a number
The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).

Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
2026-07-16 02:14:46 +02:00
Marc-André Bombeck 52d09d17c7 feat(insights): inject guideline band placement into generic metric assessments
Thread the interpretation block into the generic per-metric assessment
path (the ~40 HealthKit metric cards). For a metric the knowledge base
covers, the prompt now carries the band table plus the server-computed
position of the current value, so the assessment names where the value
sits on the guideline scale and judges the trend by that position — the
visceral-fat 2.7 case now reads as "comfortably in the healthy band"
rather than reciting how many times it was logged.

The block is absent for uncovered metrics and for a sex-split band with
no known sex, so those cards fall back to exactly today's personal-relative
behaviour. Band edges carry no strip risk here — the generic assessment
path runs no post-generation numeric grounding gate.
2026-07-07 00:36:43 +02:00
Marc-André Bombeck 3ce396081f feat(insights): anti-recitation and interpretation-depth prompt contracts
Add two shared cross-surface contracts and compose them into the
assessment cards (base-system) and the comprehensive overview + daily
briefing (insight-generator):

- anti-recitation: a measurement count, a logging cadence, or page
  mechanics is not an insight and may only appear when it carries a
  consequence — stated as the consequence, not the count.
- interpretation-depth: say what a value MEANS, place it on its guideline
  scale where one is attached, judge the trend by band position, and frame
  the consequence without diagnosis; where no band exists, interpret
  against the person's own baseline and say so honestly.

The cross-surface coverage guard now asserts both contracts reach the
surfaces that enforce them. The briefing's grounding and format machinery
is untouched.
2026-07-07 00:36:34 +02:00
Marc-André Bombeck 8fc71620e8 feat(insights): guideline interpretation registry for per-metric assessments
Add a typed, hand-curated registry mapping metrics to guideline
interpretation bands derived strictly from the health knowledge base —
band edges, plain labels, valence, direction-of-good, and a source tag,
each entry citing the KB file + section it came from. A metric the KB
gives no numeric band for gets no entry, so its assessment stays
personal-relative rather than inventing a threshold.

The band position is computed server-side (`classifyBandPosition`, strict
upper-edge semantics + edge-hugging proximity), and `buildInterpretationBlock`
renders the table + placement into the assessment prompt so the model states
where a value sits and what the band means instead of reciting counts.
`interpretationBandEdges` exposes the edges for any numeric grounding gate.

Covers visceral fat, resting heart rate, blood oxygen, respiratory rate,
body temperature, sleep duration, waist (sex-split), waist-to-height,
pulse-wave velocity, and BMI. Tests pin every band edge against the
guideline value so a silent edit breaks a test.
2026-07-07 00:36:25 +02:00
Marc-André Bombeck a20aa845d9 fix(ai): ask for blank-line paragraphs in the assessment and briefing output formats
The shared formatting contract asks every narrative surface for short
blank-line paragraphs, but two field-level instructions overrode it:
the assessment OUTPUT FORMAT pinned 'flowing prose (2-4 sentences)'
and the briefing paragraph asked for 'a connected 70-160 word read' —
so both consistently arrived as one unbroken block regardless of the
contract. Both now spell out 1-3 short paragraphs separated by a
blank line written as \n\n inside the JSON string.
2026-07-05 14:59:05 +02:00
Marc-André Bombeck e754979abf feat(ai): allow lists and one bold takeaway in the formatting contract
The shared contract banned every structure beyond 1-3 paragraphs, so
longer replies read as unformatted blocks. It now asks for short
blank-line paragraphs (2-4 on longer replies), permits a plain "- "
list when a reply genuinely enumerates three or more parallel items,
and one bold key takeaway per reply. The Coach ground rule keeps
prose-first; a list is the exception, never the default shape.
2026-07-04 23:33:17 +02:00
Marc-André Bombeck b393527cac feat(insights): verdict-first briefing, richer score cards, grounded opener rotation
Replace the fixed three-beat assessment skeleton with a menu of moves the
model orders freely, and reframe the daily briefing from per-signal
enumeration to a verdict-first read that weaves the few salient signals into
one associative story. Score cards now name both the helped and the hurt
contributors, translate the band into a forward read for the day, and close
on one grounded nudge; the deterministic fallback gains the same band-meaning
sentence so provider-less and demo accounts read as well.

Add a hash-seeded opener-archetype rotation (deterministic, testable) so
consecutive cards and days no longer open the same way, a day-rotated sampling
seed for the period narrative in place of the fixed reference seed, and a
shared paragraph formatting contract. Add a score-number grounding gate and
extend the briefing grounding allow-set to the glucose, labs, preventive-care
and workout aggregate blocks so real figures can be cited without opening the
gate. Gate the daily score warm on an input fingerprint so a quiet day skips
the model call.
2026-06-27 14:39:54 +02:00
Marc-André Bombeck fe6c2e281b feat(insights): ambient narrative presence — callbacks, tension verdict, streaks, MI moves
Add a deterministic narrative layer the Coach narrates rather than invents:

- Briefing callbacks + forward-looks: the daily briefing recalls the prior
  period and points ahead, anchored to a figure, one callback per surface.
- Tension Verdict: when the readiness composite's contributors disagree
  (good sleep but a rising resting pulse), surface a brief honest internal
  read on the health-score card. A real clinical red-flag is never reconciled
  away into a calm verdict.
- Streaks + return-to-baseline: a pure detector over a DAY-bucket series
  closes a worry once a metric returns inside the personal MAD band after a
  genuine out-of-band run; a gap day breaks a streak.
- Coach prompt: add develop-discrepancy, roll-with-resistance, and an
  anti-persuasion clause; ratchet PROMPT_VERSION.

The tension flag and streak event are computed server-side; the card lines
render resolved fields and the model narrates them.
2026-06-26 23:57:37 +02:00
Marc-André Bombeck cf331dbef0 Bind Coach safety thresholds to clinical floors and add an outlook contract
The acute red-flag clause hardcoded its blood-pressure numbers as prose
literals and named no glucose or fever floor at all, so the Coach could
silently drift from the dashboard hero and the notification engine. Compose
the systolic/diastolic, glucose, and sustained-fever thresholds straight from
clinical-floors.ts and echo the previously-missing glucose and fever lines, so
every acute number the Coach states is bound to the one source of truth.

Add an outlook contract (gentle forecast, what-to-expect, anticipatory if-then
— all conditional, ranged, association-framed) composed beside the tone
contract on the Coach and the comprehensive briefing, so a reply can sharpen
expectations without a false promise. Tighten the tone contract against
over-validation (affirm the effort, not an unsafe choice; stay neutral when
there is nothing to praise) and route why/conflict questions through
get_correlations and its coincident-deviation flag before answering.

Coverage asserts the clause numbers equal the constants and the new contract
reaches both surfaces.
2026-06-26 13:24:05 +02:00
Marc-André Bombeck 490c9d240a merge(FALLBACK): v1.21.0
# Conflicts:
#	src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
#	src/lib/insights/narrative/period-narrative-generate.ts
2026-06-26 12:40:03 +02:00
Marc-André Bombeck d22372e800 fix(insights): warm the period retrospective to the shared house voice
The retrospective narrative was the one model surface that omitted the
shared tone contract, so it read colder than the daily briefing beside it.
Compose the tone fragment into both prompts and warm the hand-written tone
line to name a genuine win when the period earns it, while keeping the
descriptive-never-causal and no-alarm guards intact. The cross-surface
coverage test now asserts the contract reaches this surface too.
2026-06-26 12:29:43 +02:00
Marc-André Bombeck 34ed1a0c93 feat(coach): add acute red-flag escalation to the shared safety contract
The shared contracts cover chronic deferral (dose, diagnosis, drug-level
to a clinician) but had no acute branch. Add a closed-list crisis clause —
chest pain, syncope, sudden severe symptoms, hypertensive-crisis readings,
suicidal ideation — that points to prompt or emergency care without
diagnosing, and keep every other turn calm and non-alarmist. It rides the
single shared-contract source so it lands on the Coach, the per-metric
status cards, the comprehensive briefing, and the period narrative; the
coverage test asserts it on all four surfaces.
2026-06-26 12:22:32 +02:00
Marc-André Bombeck db68f994c2 fix(insights): feed the six specialised cards a precomputed SIGNAL block
BP, pulse, weight, BMI, mood and medication-compliance still asked the
model to derive the recent-vs-baseline comparison from raw buckets — the
exact arithmetic small models do unreliably, and the pattern the
base-system prompt already advertises as precomputed.

Build the SIGNAL block (current / baseline / signed delta / normal-swing
verdict) with buildMetricSignal from each card's graded series and inject
it into the snapshot; BP carries two signals, compliance one per
medication. Each card's prompt section now leads from the signal block
and is told to state, not recompute. The server-computed compliance %
is unchanged.
2026-06-20 16:58:44 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 5e541ebf4f refactor(ai): drop dead status-batch reverse map, consume the budget type
Remove the unused output-key reverse map the batch path never read, and
have the budget table satisfy the AiBudget interface directly so the type
is enforced rather than orphaned.
2026-06-19 21:51:22 +02:00
Marc-André Bombeck b3b6720723 feat(insights): batch the seven status assessments into one provider call
Add generateStatusBatchForUser: it prepares the seven specialised status
snapshots once, sends one prompt for a { perMetric } envelope covering only
the metrics with data, and fans each summary back into the same per-metric
cache rows the standalone generators write. One JSON-correction retry guards
the parse; any omitted metric or a failed batch degrades to that card's own
single-call path, so a partial response never goes dark. The nightly warm and
the forced warm route through the batch in production while the injected
per-card seam keeps the cron tests intact. Adds the status-batch prompt
scaffold composed from the shared assessment contract.
2026-06-19 21:41:14 +02:00
Marc-André Bombeck 57242b9884 refactor(ai): extract shared prompt contracts as a single source
The grounding, tone/banned-opener, GLP-1 dose-safety, metric-identifier
ban and forbidden-filler rules were independently encoded across the
comprehensive, status, Coach and narrative prompts and had drifted. Move
the canonical wording into `prompts/shared-contracts.ts` (locale-keyed
fragments) and compose each surface from it.

Where the surfaces had drifted, adopt the stricter wording: most notably
the GLP-1 dose-prescription contract — previously present only in the
comprehensive and Coach prompts — now reaches the status cards and the
period narrative too. A cross-surface coverage test asserts every
enforced fragment appears verbatim in every surface, in de and en.
2026-06-19 21:11:28 +02:00
Marc-André Bombeck 0b01bfb483 feat(insights): signals-of-the-day briefing and fix the stale-briefing gate
Compute a present-focused "signals of the day" block in extractFeatures from
the in-memory measurement set (no extra query): the freshest reading vs the
user's own 7d/30d windows, the normal-swing verdict, an emerging slope, and a
recent anomaly per salient metric, ranked by clinical priority and capped at
five. Add a signalsOfDay array to the dailyBriefing schema feeding the
briefing alongside the existing keyFindings cap, and rewrite the briefing
prompt (EN + DE) to lead with NOW: a warmer, motivating read that surfaces the
emerging signal and gives one concrete, doable nudge while still drawing on the
tiered history.

The signals block lands inside the compacted features payload, so it also
seeds insightsSnapshotHash — a fresh daily signal now changes the fingerprint
and forces the daily 04:30 warm to regenerate instead of re-stamping a
days-old narrative. Bump PROMPT_VERSION to 4.29.0.
2026-06-19 19:36:51 +02:00