The response-timeout setting reached the briefing, the status notes and
the Coach, which threaded it by hand. Document reads, document
extraction, lab OCR and lab staging called the provider without a
timeout, so every client fell back to its own 60 s literal and a slow
self-hosted model was cut off however high the setting stood.
The setting now rides on the provider instance. resolveProvider,
resolveProviderChain and resolveProviderForTest stamp the record's
aiResponseTimeoutSeconds on everything they return, and each wire
client reads its ceiling through callTimeoutMs: the setting, else the
surface's own timeoutMs, else 60 s. A call site no longer has to pass
it, so a new one cannot forget it. The connection test runs under the
same ceiling a read does.
The Coach nudge tick and the reaction line keep their short ceilings
(timeoutPolicy: "surface-ceiling"): the tick runs accounts one after
another under a shared budget, the reaction line's claim lease is
shorter than the largest setting, and both have deterministic copy
that stands in.
/api/auth/me publishes the effective value as
ai.provider.responseTimeoutMs, so a client can size its own request
timeout from it instead of guessing.
Refs #1090
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
The cycle block's headline contrast labelled weight in kg and the
temperatures in °C, and left a glucose contrast without any unit. The
metric table, which the person sees and whose summary the model reads,
listed weight in kilograms, temperatures in degrees Celsius, walking
speed in metres per second and distance in metres.
The cycle contrast now converts both means and the gap into the
reader's units (the gap by the factor alone) and names the unit in its
sentence. The table resolves each column's unit and decimals from the
reader's preferences and converts every folded period value once; the
snapshot carries the preferences it was written in, so the table and
the snapshot of a turn always agree.
Refs #1067
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
The reference grounding printed the fasting glucose bands in mg/dL and
the body-temperature band in °C, and the system prompt described time
in range as 70-180 mg/dL. For a reader on mmol/L or Fahrenheit the
model was handed one quantity in two units: the glucose block in
mmol/L, the band beside it in mg/dL.
The grounding block now takes both unit preferences and prints the
general, the diabetes goal and the temperature band in them, while the
placement is still classified on the canonical value. The glucose
block carries the time-in-range band in its own unit, and the prompt
points the model at it instead of naming a unit.
Refs #1067
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
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.
- requireFloor(label, found, floor) throws below the floor and refuses a
floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
and floors both the files read and the hits found.
Floors added, pinned at the current counts:
- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
(399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
are equal.
The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.
The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.
Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:
- cycle calendar, insights, Coach snapshot and prediction refresh start
their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening
49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
The snapshot cut the last injection, the dose history and the projected
next injection on UTC days and compared getUTCDay() with the schedule's
local weekday. An evening injection west of UTC landed on the next day,
and east of UTC 'tomorrow' was today. Day keys now come from the user's
zone, which the Coach snapshot already resolves and now passes in.
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
"As a table" used to drop the table's chart, so the reply offered no way
back to it. The copied table now keeps its chart and carries view: table,
which the panel opens on the table (the chart one tap away on the toggle)
and the chips read to offer "as a chart". show_result with view table
does the same. The field is optional on the result metadata; a client
that ignores it shows the chart first as before.
The Coach's recent nights carried the wake instant in UTC as their date,
while the sleep table under the answer keys a night by the local day it
ends on. East of UTC the two named different days for the same night.
The snapshot now gives the night's own key.
The recheck and follow-up rules now ride the tool-mode prompt only once
the conversation holds a table, or from the round after the turn produced
its first one. The clarifying-question rules are shorter and English for
every locale, like the other instructions to the model; the German copy
of them and of the recheck rule is gone. A turn without tables sends
about 1 300 characters less on every round.
The golden transcripts change only in the system prompt's hash and
length (4 509 to 3 187 characters; 4 728 to 3 280 for the German turn).
Table refs, step ids, chip ids, a chip's anchor ref, clarification choice
ids and a reused table's source are now validated against their exact
server-minted patterns when a stored reply is read back. Some of them are
written into the model's context on a later turn (the earlier-tables list,
the "keep looking" fetched list), so a stored row edited by hand can no
longer carry text into a prompt through them.
show_result now passes the same scope gate a fresh read does before it
decrypts a stored table: a metric the person has since excluded from the
Coach, or one outside the conversation's scope, answers
unavailable_in_scope and nothing is sent. The list of earlier tables in
the context drops those metrics too, so the model is not told they exist.
The exclusion (the person's own list plus switched-off modules, and no
adherence when medications are excluded) is the snapshot builder's, now
one exported helper both paths use.
"Keep looking" no longer copies the unfinished question into the
server-written context lines. The question stays in the transcript as the
person's own turn; the line only points at it. Lifted into the system
role, whatever the question said would have carried the system's
authority.
A turn now reads its conversation's newest messages once and shares the
rows between the chip, clarification, "keep looking" and repeat-question
checks, instead of three identical reads.
Earlier tables keep their m<k> names once a conversation outgrows the
window a turn loads: the index counts the assistant messages before the
window too, so a name the model read on an earlier turn still points at
the same table.
The backup validation now reads the per-message table ceiling from
results/refs, which imports nothing but types. Reading it from
results/project pulled the tool executor and the message store into the
snapshot builder's import graph, and the free-text sanitiser guard then
walked files that never build a prompt.
The grounding ledger derives differences only between figures of one
payload, so a correct "up 4 mmHg on last week" drawn from two separate
table reads was withheld as unverified. A table of the period before or of
a year ago now reads the current window too, and its summary carries the
current figures and the change (delta and percent change) beside the
earlier ones. A wrong delta is still flagged.
The live evaluation now captures the tool calls a generation makes, with
their arguments, and hands them to the judge. runScenarioLive puts a
scenario to the model in the chat turn's own context (system prompt,
tool-mode and dialog addenda, DATA INVENTORY, EARLIER TABLES, transcript)
over the real tool catalogue, answers each call with a stand-in result,
and reads the reply through the turn's clarification and chip parsers.
runScenarioJudge grades each scenario a few times with the same
evaluateScenario the deterministic suite uses and reports a pass rate per
scenario; the server-only scenarios and checks are left out.
Like the existing judge it runs only with COACH_EVAL_API_KEY, from the
nightly workflow's non-blocking step, and never throws.
A withheld table now leaves a line saying why (module off, or the stored
tables could not be read) and a failed read shows an error row with a
retry, so the Data used count matches what is listed. A table copied from
an earlier answer names that answer's date under its title. The preview
shows the latest twelve periods of a time series, and the truncation note
says the latest rows are kept, which is what the server keeps. The title,
copy and view controls sit above the scrolling table with the title kept
as a hidden caption, and a reply with tables takes the full column width.
The step headers and the related-metric chip read as label: value in all
seven locales, so a metric name no longer sits capitalised mid-sentence.
The "compare with the period before" chip now needs a first reading
before the window's start, the same history check the other history chips
use, and is never offered on an all-time table, which has no period
before it: the table tool reads all time as the current period whatever
it is asked.
Tables are now projected outside the provider's failure path: a
projection defect costs the turn its tables and is annotated, instead of
refunding tokens already billed and dropping the reply.
The at-rest ceiling for a message's tables is applied once, when the
dialog is assembled, before anything streams. A table trimmed only at
write time used to show live and read back as withheld after a reload.
Ten scenarios, each in German and English, describe what a turn must do
with a question: which tool it calls and with which arguments, whether it
may read anything new, whether it asks back, declines a dose, offers
chips only for what the record holds, answers a chip without a provider,
offers to keep looking after a forced answer, and keeps an earlier
table's title out of the model's context. evaluateScenario grades a run
against them.
The deterministic suite drives POST /api/insights/chat with a scripted
provider; the tool loop, executor, table tool, chips, clarification
filter, method line, reply guards and stream run for real. Every check
also runs against a misbehaving model or a broken record and must fail
there.
route-followups.test.ts pins the front door for chips and answered
questions: validation, the rate limit and capability gate on a reuse
turn, the stored chip winning over anything the request adds, stale
chips and answers degrading to a plain message, and the continue line.
A total's weeks and months are now the sum of its days, labelled as a
total, and a level's stay a mean. All time is read as UTC calendar months
from the rollup tier's MONTH fold, or the same fold of the live table when
the tier has no rows, so a month no longer reads as a mean of daily totals
for a short history and a monthly total for a long one. Months are keyed
by their UTC start instead of being re-read in the user's zone, which
named every month one early west of UTC.
The chart's long ranges had the same shift: tier rows are now stamped at
noon UTC of the bucket's first day, which falls inside the bucket's own
week and month in every zone.
Step length and walking speed keep two decimals, loud-sound events count
as a total in whole events, and HRV counts a night measured by both
estimators once and charts the estimator the person mostly has. The model
summary names what a period's value is and gives a total's range total.
The outbound screen matched "Erhöhe auf 2,4 mg" but not "Erhöhe deine
Dosis auf 2,4 mg": the German change verbs had no slot for an object
between the verb and the target, unlike the English "increase your dose
to". The raise and lower patterns now accept one.
Asking for an earlier table "as a chart" went through show_result, which
builds the requested view, but collecting the turn's tables then reset
every chart to null and only derived a new one for fresh tables. The table
came back without its chart. A table shown again now keeps the view it was
given.
When the tool loop forces an answer at its round cap, the reply leads
its chips with "Keep looking". Tapping it runs a normal turn, under the
budget gate as ever, with two server-written context lines: the question
that was left unfinished (at most 1000 characters) and what the forced
turn already read, each table by the name show_result takes, so the
continued turn shows those again instead of fetching them twice.
An answer is continued at most once. The continued reply records the
message it continues in metricSource.continuationOf and offers no
further "Keep looking", even when it is forced again; a chip on a reply
that is itself a continuation is refused.
Up to three chips, derived on the server from what the turn read: the
other view of a table with a chart, the period before, a year earlier,
a wider window, and a metric a discovered pattern links to. A chip is
offered only when its read was done this turn and the record holds the
metric; the year-ago and wider-window chips also need the record's
first reading to reach back that far. The model may name chips in a
FOLLOWUPS block, but only to reorder ones the rules already allow; every
label is rendered from the catalog.
A tapped chip resolves against the chips stored on the conversation's
latest reply. "As a chart" and "as a table" are answered from the
stored table without a model call or a budget reservation: the rows are
copied unchanged and the message is tagged providerType "reuse". Other
chips run a normal turn with a server-written context line. A chip that
is no longer current falls back to a plain message.
A setting turns the chips off (absent means on).
A table over time is drawn as a line for a level metric and as bars
for a total, with at most two series in one unit. Category counts get
bars, horizontal above six categories, the top eight and one other.
Fewer than two or more than 400 rows, or no numeric column, stay table
only. show_result's view now counts: table drops the chart, chart turns
an earlier day table into counts per range with a histogram (fixed
widths for the common metrics, Freedman-Diaconis otherwise, none below
ten values).
The method line merged a repeated read by keeping the larger count. With
show_result that could let an earlier answer's count stand over the read
this turn just made. A fresh read of the same source, window and period
now always wins, whichever order the steps ran in, and the two are never
added. An entry that rests only on an earlier table says so ("from an
earlier answer"), including a reused table no step accounts for.
The new catalog string is in all seven locales.
get_metric_table now gets a step like every other read: the domain is
its validated metric, and once the table is in, the window, period and
granularity are the ones the table was built with (after the executor's
defaults). The count is the readings the table folds, taken from the
summary the server computed, so a read past the sixth table still counts.
A step whose call produced a table now names it (resultRef), for the
projected tables of the older tools as well.
show_result gets its own step. It reads nothing new: while it runs it
names no domain, and once the stored table is found it takes the source
of the read that built it and the name the copy carries in this turn. A
table that could not be shown is a failed step with the reason, never an
empty read.
The property test now drives show_result too and checks resultRef; the
coverage test gives each tool the smallest arguments it accepts instead
of passing empty arguments to a tool that requires a metric.
get_metric_table reads one metric as days, weeks or months through the
chart reader, cut in the user's zone and collapsed to one source per day,
with null for a period without a reading. The model reads a bounded
summary; the full table rides the turn, encrypted at rest. show_result
shows a table an earlier reply of the same conversation fetched, resolved
only against that conversation's own messages. Workouts, labs, sleep and
adherence results project into tables, earlier tables are named in the
context by metadata alone, and result:rN marks are stripped from the reply.
The evidence disclosure under a Coach answer now carries one muted
line: which sources and windows the turn read, how many readings, and
how they were aggregated ("Blood pressure, last 90 days: 142 readings,
weekly averages · Sleep, last 30 days: no readings in this window").
The server builds it from the recorded steps and tables in the request
locale; it holds catalog strings and counts only, never a reading, and
a source that turned up nothing says so.
The tool-mode prompt gains a recheck rule: when the person questions a
figure, name the source and window, read it again (or reuse the stored
table), and correct the answer if the two differ.
Each tool call now emits a running step as it starts and its final
status as it settles: found with a server-counted number of readings,
empty with the reason, or failed. The no-tools path shows one step for
the record summary. Steps carry catalog labels rendered in the request
language, closed-enum domains and windows, and integers only; a lab
analyte, a value or a model-written argument never reaches one. At most
twelve per turn. They persist on the provenance, so a reload shows the
same list.
The conversation window reaches the step so a call without a window
names the one it actually read. The no-tools transcripts gain the
summary step; a new transcript pins found, empty and failed steps ahead
of the first token.