Commit Graph
6805 Commits
Author SHA1 Message Date
Marc-André Bombeck 1bbd2d97c7 Merge branch 'fix/v147-screen-negations' into release/v1.47.0 2026-11-04 00:17:01 +01:00
Marc-André Bombeck 1a4703f4fe feat(insights): name the part of the day on the blood pressure stats while narrowed
The stat strip reads the chart's visible window, so with the chart
narrowed to the morning its figures were morning figures under a plain
Systolic label. The column label now says so. Russian texts for the
time-of-day card.
2026-11-04 00:17:00 +01:00
Marc-André Bombeck 62d1f57de8 fix(ai): block a negated take, a pause, an increment and a liquid dose
Four instruction shapes passed the outbound dose screen in every language:
a negated intake ("Don't take atorvastatin tonight", "Nehmen Sie kein
Atorvastatin"), a pause ("Take a break from enalapril"), an increment with
no target ("Add 4 more units of insulin"), and a measured volume of a liquid
drug form ("Take 10 ml of the syrup").

Each bank gains the shapes, anchored on a medication noun, a drug unit or a
liquid drug form rather than on the verb. A negated intake counts as a hold
only when the clause closes or a time word follows and no protective
qualifier sits in the object ("a double dose", "more than", "half"), so the
standard warnings still pass. `ml` stays out of the drug units: the liquid
shape needs a syrup, solution, drops or medication noun beside it, so
"drink 500 ml of water" passes.

The Russian and Korean banks now know the generic-name stems in their own
script (INN_STEM_CYRILLIC, INN_STEM_HANGUL), and the English object slot no
longer crosses another language's negation, which had blocked the Dutch
warning "Stop niet met ramipril zonder overleg".
2026-11-04 00:14:56 +01:00
Marc-André Bombeck 22f83aed6e Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0 2026-11-04 00:13:45 +01:00
Marc-André Bombeck b93241aa87 feat(insights): blood pressure by time of day
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.

The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.

`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.

Refs #1331
2026-11-04 00:12:49 +01:00
Marc-André Bombeck cac0668119 test: read the Dockerfile through readSource in the wget shim test
Plus six Russian strings smoothed in review.
2026-11-03 23:52:57 +01:00
Marc-André Bombeck b2fb295d43 Merge pull request #1322 from l1137l/feat/ru-locale 2026-11-03 23:50:35 +01:00
Marc-André Bombeck 0007973b78 fix(ai): tighten the ru dose bank
End a dose unit at a Cyrillic letter, so «3 месяца» no longer reads as
"3 МЕ". Keep ml out of the drug units as the other banks do, and stand
the target-dose shapes down on a dietary noun (water, sodium, caffeine),
which blocked plain nutrition advice. Cover the stop and inject verbs
(перестаньте, бросить, колите) the English bank already blocks.
2026-11-03 23:45:36 +01:00
Marc-André Bombeck 3e2e2ad56b Merge branch 'pr-1322' into review/pr-1322 2026-11-03 23:37:22 +01:00
Marc-André Bombeck 2d8b782f34 Merge branch 'fix/v147-review-dbrole' into release/v1.47.0 2026-11-03 23:35:54 +01:00
Marc-André Bombeck a10bebdc43 fix(db): give each database its own app role and keep the queue schema the owner's
Roles are cluster-wide. Two HealthLog databases on one server shared
healthlog_app: each start set its own password, locking the other
instance out, and the role held grants on both databases. The role is
now healthlog_app_<database>_<hash>, marked with a comment naming its
database, its password is derived per database, and a role holding an
explicit CONNECT on another database is not used.

The role owned the pgboss schema, so it could plant a trigger, a rule or
a function body that a later owner session (a rollback, DB_APP_ROLE=off,
a fallback start) would run with the owner's rights. pg-boss changes its
schema only on install, on migration and for partitioned queues, which
the app does not use. The start now installs and migrates the schema as
the owner with pg-boss's own plans, and the worker runs pg-boss with
migrate: false when it does not own the schema. The role gets rows only,
read-only on pgboss.bam (SQL the migration runner executes) and on the
version column. Every start, on every path, takes back what a
HealthLog role owns there, drops triggers and rules, and after a
take-back restores the queue functions to pg-boss's own definitions and
retires the legacy role. Bloated queue indexes, which pg-boss only
reports when it does not own them, are rebuilt at start.
2026-11-03 23:31:37 +01:00
Marc-André Bombeck 41e75b5bde Merge branch 'fix/v147-review-pdf' into release/v1.47.0 2026-11-03 23:30:26 +01:00
Marc-André Bombeck da48f1af9e Merge branch 'fix/v147-review-wolfi' into release/v1.47.0 2026-11-03 23:30:25 +01:00
Marc-André Bombeck 7af9ebe480 docs(stats): say what the r² clamp does and does not fix 2026-11-03 23:30:21 +01:00
Marc-André Bombeck 0e7134a2ea fix(labs): let a deleted number not hold a text marker's unit, and read the winner's unit after a race
Refs #1313
2026-11-03 23:30:21 +01:00
Marc-André Bombeck 55a3a9b53d fix(medications): keep the rrule anchor for a rule with BYSETPOS
The library drops a period's candidates before DTSTART before BYSETPOS
picks among them, so a start moved into the middle of a week chose a
different day. The property now generates BYSETPOS rules and kept red
without the guard.
2026-11-03 23:30:20 +01:00
Marc-André Bombeck 42cae738f1 build: pin the runner's Node and PostgreSQL client packages
nodejs-22 and postgresql-18-client are installed at exact package
versions, so a rebuild of the same commit runs the same Node and the
same pg_dump; apk upgrade still moves the libraries under them. No
updater reads an apk pin. The image scan does the job instead: Wolfi
records the fixed version for each advisory, so a pinned package with a
published fix turns the HIGH/CRITICAL gate red and names the version.
A guard holds the pin format and keeps the client's minor on the bundled
database's.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck a11ea8444d build: ship a wget for health checks written outside the image
The Wolfi runner has no wget, but health checks outside this repository
call it: compose files copied from here up to v1.46.6 run
'wget --no-verbose --tries=1 --spider .../api/health', and the TrueNAS
catalog app renders 'wget --quiet --spider <url>'. Either replaces the
image's HEALTHCHECK, so such a container never turned healthy and a proxy
that routes on health dropped its traffic.

/usr/local/bin/wget now runs scripts/wget-shim.mjs: the URL, --spider,
-q, -nv, --tries, --timeout/-T, -O - and -O /dev/null, -S; other flags are
ignored. Exit 0 on 2xx/3xx, 1 on any other status, a refused connection
or the timeout. It follows no redirect and refuses any other output file
before sending a request, so it is no download tool. The image's own
HEALTHCHECK and the bundled compose file keep the Node probe.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck 253176d01a fix(documents): index every page of a PDF read by its text layer
"Read with AI" and the index backfill wrote a text-layer read as a complete
`local-pdf` index although it held only the first ten pages (the render
cap), while the local text index reads up to 200. `prepareVisionInput` takes
the number of pages a text-layer read may take, and both index writers ask
for the local index's own cap, so the index is complete.

The `partial` flag on a text read had no reader and is gone; the
extraction and the lab scan report their missing pages themselves.

Refs #1311
2026-11-03 23:24:49 +01:00
Marc-André Bombeck 763d6c600f fix(labs): bound each text-layer read of an unrenderable PDF
`planTextLayerReads` grouped whole pages into the reads the deadline
allows, with no bound on a read's size, while the text path refuses a part
past `OCR_TEXT_READ_MAX_CHARS`. A dense report in a request's three reads
went out as reads of 12 000 characters and more, which ends at the output
limit or loses rows.

`planTextLayerChunks` now cuts a page longer than one read at line
boundaries, keeps a page per read while the deadline allows that many, and
otherwise packs pages into reads of at most twice the surface's target
(8 000 characters for the lab scan, 5 000 for the document extraction).
What the reads cannot take is read from the start, and the pages left over
are reported with the pages without a text layer: `unreadPages` on the lab
scan, `extractionIncomplete` on a document.

Refs #1311
2026-11-03 23:24:38 +01:00
Marc-André Bombeck 119431dc15 fix(documents): fence document text in the summary and the filing suggestion
Both text modes spliced the text after a bare `OCR TEXT:` label, while the
extraction, the lab scan and the label read fence it. A PDF read by its
text layer on a server that cannot render now reaches these prompts, and
the automatic summary reads imported PDFs that way, so text the page does
not show (white on white) could write past its own end. The text is now
fenced with `fenceDocumentText`, the browser-OCR path included, and a marker
forged inside it is scrubbed.

Refs #1311
2026-11-03 23:24:26 +01:00
Marc-André Bombeck b975e86b39 test(clinician-share): start every case on an all-modules-on map
Two cases switch the doctor report off for the rest of their block, and
the blocks that did not set the map themselves inherited it under a
shuffled order, so a record share stopped aggregating.
2026-11-03 22:39:16 +01:00
Marc-André Bombeck 144840bab9 test(documents): type the fixtures the text-layer cases added instead of casting them
The stored bytes are already a Buffer and the MIME literal already fits
its union, so neither needs a cast; the external pick goes through one
helper. The as-never ratchet tightens accordingly.
2026-11-03 22:36:18 +01:00
Marc-André Bombeck 7cb67c2e75 Merge branch 'fix/v147-nightly' into release/v1.47.0 2026-11-03 22:30:50 +01:00
Marc-André Bombeck a598b00081 test(coach): start every snapshot case with no readings
The query-shape case read the measurement mock without setting it and
passed only after a case that left rows behind.
2026-11-03 22:25:43 +01:00
Marc-André Bombeck 1ac69420d5 test: reset to the factory answer instead of casting a new one
A reset mock goes back to the implementation its factory gave it, which
is the fresh session and the open capability these cases want; the cast
replacements grew the test-realism count.
2026-11-03 22:22:32 +01:00
Marc-André Bombeck 9a3140a4d3 test(backup): hold the record-shaped slices at their edges
The health score day, the intraday curve, a medication with its
composition and custom category, an intake row and the cycle's own
symptoms had fields and defaults no slice test met, added since the
floor was set; the backup-validation score had drifted to 40.2 % under
its 41 % floor. It is 54.1 % with these cases, and the floor follows
to 53 %.
2026-11-03 22:15:05 +01:00
Marc-André Bombeck b1cb387062 Merge branch 'fix/v147-pdf-unrenderable' into release/v1.47.0
# Conflicts:
#	.env.production.example
2026-11-03 22:10:14 +01:00
Marc-André Bombeck 3547c4bd2f test: pin the intake history's success badge at the one it now renders
The taken badge moved into one shared component, so the file carries the
token once.
2026-11-03 22:08:05 +01:00
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00
Marc-André Bombeck c47f61f02a feat(documents): read a PDF's text layer page by page without rendering
The reader child gains a `page-texts` request: the first pages' text
layers, each read the way a rendered page's is (cut and marked past the
per-page cap), plus the document's page count. Nothing is rendered, so it
works where the page renderer cannot run.

`readPdfTextLayer` judges what that read is worth: a page counts only with
at least 40 characters of its own besides white space, so a scanner's
stamped page number or fax header does not pass for a page of text; a PDF
with no such page is a scan and is reported as one.
2026-11-03 22:07:11 +01:00
Marc-André Bombeck d1ab195b43 Merge pull request #1330 from FloMaetschke/feature/injection-site-in-intake-history 2026-11-03 22:06:35 +01:00
Marc-André Bombeck a503c8855d Merge branch 'feat/v147-wolfi' into release/v1.47.0 2026-11-03 21:56:23 +01:00
Marc-André Bombeck 5d36afb487 build: run the image on a Wolfi runner
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.

nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
2026-11-03 21:55:30 +01:00
Marc-André Bombeck 899a054e64 Merge branch 'feat/v147-mcp-reading-id' into release/v1.47.0 2026-11-03 21:33:50 +01:00
Marc-André Bombeck ba27e728cf feat(mcp): fetch one lab reading by its own id
A link to a lab result named its analyte, and fetch lab:<analyte> answers
with the latest reading of that marker. Following a link from a report that
holds an earlier draw therefore landed on a later reading, whose links named
a different report and visit.

fetch labresult:<id> now returns that one reading (value or text result,
unit, reference range, status, date) with its own links, behind the labs
module and narrowed to the caller's live rows. Every link to a lab result
uses this id, each reading in get_labs carries it, and fetch lab:<analyte>
names the reading it quotes in metadata.readingId. The analyte view and
search are unchanged.

Refs #1323
2026-11-03 21:33:16 +01:00
Marc-André Bombeck 691c2e7301 refactor(labs): shape one reading from one shared select
The labs block and a single-reading read now build a reading from the same
select and the same shaping, so one reading fetched by its id renders exactly
like the latest reading of a marker.
2026-11-03 21:33:14 +01:00
Marc-André Bombeck cd344125d5 fix(labs): let a text-only marker take the unit of its first number
A marker minted from a text result ("SEE NOTE") has an empty unit, and a
marker with a reading has its unit locked, so the first number to arrive,
a unitless ratio included, was refused against a unit nothing had chosen.
While every reading is text the unit has nothing to contradict, so the
first number sets it. A marker that already holds a number keeps its unit.

Refs #1313
2026-11-03 21:19:28 +01:00
Marc-André Bombeck 377e0d0d3a build: copy pg_dump and pg_restore from the PostgreSQL image
postgresql-client-18 from the PGDG repository brought Perl and Debian's
client wrapper scripts into the runner for two binaries the app calls by
name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now
hands over pg_dump, pg_restore and the libpq they link; the Debian
libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the
upgrade and the image scan see them. The runner registers libpq with the
loader and fails the build when either binary has an unresolved library;
the major 18 version checks stay.

The client minor follows the bundled database's: a guard holds the two
pins together, and the Dockerfile's Dependabot watch now ignores a
PostgreSQL major the same way the compose watch does.
2026-11-03 21:18:44 +01:00
Marc-André Bombeck cf7905dc92 refactor(medications): hold recurrence.ts at its size baseline 2026-11-03 20:58:46 +01:00
Marc-André Bombeck 227c80878c test: give every case its own mock state under a shuffled order
Cases leaned on what an earlier case set up or left behind: a once-value
the code under test never consumed, an implementation swapped in and
kept by clearAllMocks, a payload only another block's setup provided,
the zone resolver's per-user cache, module state in a hoisted object,
and a scope set one case discovered for the next. Each now resets or
states what it reads.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 4f7802ba28 test(browser): leave no state behind for the next case
Await the form's explicit unmount: an unmount left running overlaps the
next render's act, and React then commits nothing for the rest of the
file. Return to the newest history entry after stepping back, so the
next case's push adds an entry instead of replacing the forward one.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 54d0647a6d fix(stats): keep r² within [0, 1] when y sits far from zero
The centred sums lose their last digits when every y is close to one
large value, and a two-point fit came out at r² 1.00000004. Clamp the
ratio to 1 and the y sum of squares at 0, and pin the two shrunk
counterexamples the nightly property run found.
2026-11-03 20:58:37 +01:00
Marc-André Bombeck 270c6ca619 refactor(medications): keep the rrule start alignment in its own module
recurrence.ts sits on the source-size ratchet; the helper and its reasoning
read on their own.
2026-11-03 20:57:46 +01:00
Marc-André Bombeck 3d0ae05192 docs: say the published image renders documents on a Raspberry Pi 4
The image runs the glibc canvas build now, which needs no ARMv8.2
features, so the Pi 4 gets thumbnails and scanned-PDF rendering back
without a code change: the gate only ever closed for musl on arm64. The
gate's comment, the env example and the getting-started prerequisites
say so, and keep NATIVE_CANVAS as the override. The x64 AVX2 gate stays:
the glibc x64 build rendered a PDF page under an emulated core without
AVX2, but it still carries AVX2 code and that one run does not cover
every path.
2026-11-03 20:53:28 +01:00
Marc-André Bombeck de7858100f build: run the image on Debian trixie instead of Alpine
Every stage now starts from node:22-trixie-slim, pinned by its multi-arch
index digest, so the native modules load their glibc builds. The arm64
musl Skia build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with
SIGILL on an ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the
glibc build runs there (#1311).

The runner keeps what it had: tzdata, TZ, the build-time OS upgrade, the
nextjs user (uid 1001, now in group nodejs gid 1001), the backup and
GeoLite2 directories and every build-time assertion. pg_dump and
pg_restore of major 18 come from the PGDG apt repository, whose signing
key is fetched by node and checked against a pinned SHA-256 before apt
trusts it; PATH names the major 18 directory so the binary does not
depend on Debian's version-picking wrapper. The canvas hoist takes the
-gnu binary package by the pinned version and still fails the build on a
mismatch. bookworm-slim was the other candidate; on the day of the
switch it carried a CRITICAL zlib finding that trixie-slim does not.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 65e35761ec build: probe container health with Node's fetch instead of wget
The probe now needs nothing but the node binary the image already runs,
which frees the base image from carrying wget. The image HEALTHCHECK and
the compose healthcheck use the same one-liner: a non-2xx answer, a
refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout.
The boot-storm guard reads the abort from the fetch and refuses a probe
that names wget or curl.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 775933b7d5 Merge branch 'feat/v147-dbroles' into release/v1.47.0 2026-11-03 20:52:17 +01:00
Marc-André Bombeck 485161a657 feat(db): run the app as a role that cannot change the schema
After the migrations the entrypoint ensures healthlog_app, a login role
with row access to the app's schema, MAINTAIN on measurements and
ownership of the pgboss schema, and starts the app with a DATABASE_URL
for it in place of the owner's. The role cannot alter or drop tables,
create roles, schemas or functions in the app's schema, read server
files or run programs, or write the migration history.

The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a
separate worker container derives the same one, and it is sent as a
SCRAM verifier. Every boot re-applies the grants in one transaction
under an advisory lock, and drops as the owner the indexes the
maintenance job may no longer drop. When the owner cannot create roles,
the server is older than Postgres 17, or the role cannot log in, the
boot says why and the app stays on the owner as before. DB_APP_ROLE=off
skips it.
2026-11-03 20:36:37 +01:00
Marc-André Bombeck 95525a0259 fix(jobs): let pg-boss create its schema only when it is missing
Postgres checks the database's CREATE privilege before it checks
whether the schema exists, so CREATE SCHEMA IF NOT EXISTS fails for a
role that may not create schemas even when the schema is there. The
worker now asks first and passes createSchema: false when it exists.
2026-11-03 20:32:53 +01:00