Health Diary -- developer guide
The Health Diary lets a patient describe how they feel, in their own words or by voice, and get a short answer with next steps. Each exchange also becomes a dated diary entry they can show a doctor. Issue #2291, PRs #2417 and #2518.
It lives in two places:
| Half | Where | Read first |
|---|---|---|
| Backend | backend/domains/patient/**/health_diary/ (module Patient::HealthDiary) | services/health_diary/send_message_service.rb |
| Web app | patient/src/features/health-diary/ | hooks/useDiaryQueries.ts |
The mobile app talks to the same API. Its integration guide is Mobile integration, and it is the contract of record for request and response shapes. This guide explains how the code behind that contract works, and what to keep true when you change it.
Other documents: product spec,
how a patient uses it,
user guide,
QA guide. The backend and UI plans that drove
the build are in docs/plans/; they are working documents and not part of the docs site.
1. The promises the code keeps
Most of the design follows from five promises. When a change would break one of them, it is the wrong change.
- A patient's words are never lost. Their message is saved before the model is called, so no later failure can take it. The UI never removes a message it could not send.
- Every answer carries the disclaimer, and an emergency answer starts with guidance. Our code adds both. The model cannot remove them.
- There is always a route to care. Every answer offers "See Care Options", whatever the model suggests.
- One person's diary never shows in another's. This holds for patients and for caregivers switching between the people they manage.
- No audio is kept. A voice note is transcribed and dropped.
2. Data model
Seven tables in the patient schema. Migrations are in
backend/domains/patient/migrations/2026091*.
| Table | Holds |
|---|---|
health_diary_folders | The patient's folders. Four defaults (general, diabetes, back-pain, personal) are created on first use. |
health_diary_threads | Conversations. Each belongs to at most one folder. |
health_diary_messages | Both sides of each exchange. role is user or assistant. |
health_diary_ctas | The next-step buttons on an assistant message. |
health_diary_entries | The diary entry made from each exchange, with tags. |
health_diary_summaries | Cached "what to tell your doctor" summaries, one per period. |
health_diary_llm_call_logs | Cost and reliability telemetry for model and speech calls. No message text. |
Things that are easy to get wrong:
- Ownership is
user_id, not RLS. These tables carry no tenant, because a patient writing in their own diary belongs to no clinic. Every read is scoped to the patient in code (see section 6). Each row also hasrecorded_by_user_id, the person who typed it, which differs fromuser_idwhen a caregiver writes for a dependent. - These tables are not
Patient::Owned. That concern adds asourceprovenance column (self_reported,clinical_imported, ...), which means nothing for a chat message.HealthDiary::DiaryOwnedkeeps the parts that apply: ownership, the subject and actor pair, and soft delete.domains/patient/test/foundation_test.rbexcludes the diary tables from the "patient-reported" family on purpose. - Message ids are uuidv7, which sort by creation time. The id is the paging cursor (section 5).
reply_to_message_idlinks each assistant message to the patient message it answers. The replay logic depends on it; do not look replies up by position.client_message_idis unique per patient (idx_hd_messages_idempotency). It is the idempotency key for a send.
structure.sql is hand-spliced for diary migrations: under PostgreSQL 18, db:migrate rewrites
thousands of unrelated lines. After a new migration, keep only its own lines, then check the file
loads into an empty database.
Request logs. content is in config.filter_parameters (exact match), so what a patient
types logs as [FILTERED]. Any new param that carries patient text needs the same.
3. Sending a message
POST /patient/health-diary/chat/messages goes to MessagesController#create, which calls
SendMessageService.
SendMessageService#call
refuse blank content
replay_of_earlier_send (same client_message_id seen before? see 3.2)
Safety::Pipeline.check_input (moderation, emergency detection)
prepare_rows one transaction:
find or create the thread
save the patient's message <- the note is now safe
save an EMPTY assistant row, status "pending"
complete_with_model outside any transaction:
PromptBuilder -> Shared::LlmCallService -> ResponseParser
Safety::Pipeline.check_output
persist_reply one transaction, fenced (see 3.3):
write the answer (+ guidance + disclaimer)
CtaBuilder -> ctas
create the diary entry, tagged3.1 Why the order matters
The patient's message and the pending reply are saved before the model is called. So when the model fails, times out or the process dies, the note already exists. That is what makes the web app's "your note is saved" message true. The model call happens outside any transaction because it can take up to 90 seconds, and a transaction held that long would tie up a database connection.
Every way out of complete_with_model leaves the reply complete or failed. It is never left
pending. A rescue StandardError marks it failed, reports the error, and returns
internal_error.
3.2 Idempotency and retry
The client makes one client_message_id per note and sends it with every attempt at that note.
What happens on a repeat depends on the saved reply:
| Reply state | Result |
|---|---|
complete | The original exchange is returned. Nothing is sent to the model. |
pending, updated in the last 3 minutes | 409 duplicate_request. Another request is working on it. |
failed, or pending for longer than STALE_PENDING_AFTER (3 minutes) | Resumed: the same saved note is sent to the model again. |
| Same key, different words | 409 idempotency_conflict. A key belongs to one note. |
A resume first claims the reply with a guarded update_all on its current status and
updated_at, so two retries cannot both resume it.
3.3 Fencing: only the owner writes the reply
A resume can overlap the original request if that request was slow rather than dead. Without a fence, both would write the reply, leaving two sets of buttons and entries, or a late "failed" over a finished answer.
Each request records its claim: the reply's updated_at as stored, read back from Postgres
(take_ownership). persist_reply locks the row and checks the claim first. mark_failed only
changes a row it still owns. A request that has lost its claim throws its answer away and returns
duplicate_in_flight.
Read the claim back from the database, never from Time.current. Postgres stores microseconds
and Ruby has nanoseconds, so a value from the clock never compares equal.
3.4 What the code adds to every answer
compose_answer builds the answer text:
[emergency guidance] only when Safety::EmergencyDetector flagged the input
model answer
disclaimer always, from I18n key health_diary.disclaimerCtaBuilder builds the buttons: at most Cta::MAX_PER_MESSAGE (3), and "See Care Options" is
always one of them. Its slot is reserved before the cap is applied, so the model's own
suggestions cannot push it out. An emergency answer leads with "Talk to a Doctor": it is moved to
the front even when the model already suggested it, because left in third place the cap dropped
it. Button data from the model is reduced to folder_name, the only field a button uses.
Text we add is looked up with HealthDiary::Locale.for(language), which falls back to English
when the app has no translation file for that language (Arabic, for example). The patient's
language still drives the prompt and the model's answer.
3.5 Safety seam
Safety::Pipeline calls Moderation and EmergencyDetector. Both are deliberately permissive for
now: moderation allows everything, and emergency detection uses a short English starter list.
Plan unit U10 is waiting on a clinician-reviewed term list and a moderation provider. The
permissive setting is not a bug; do not advertise emergency detection until U10 lands.
Terms are matched from the start of a word, so "fitting" does not match "outfitting" but
"seizure" still matches "seizures" (HealthDiary::TermMatch). Terms in Indian scripts use plain
substring matching, because a regex word boundary can fall inside a syllable.
4. Voice notes
POST /patient/health-diary/chat/messages/voice (multipart) goes to MessagesController#voice.
voice
saved_note: key already used?
typed note -> 409 idempotency_conflict (before reading any audio)
voice note -> send the stored words again (no second transcription)
AudioUpload.validate container allowlist, <= 4 MB, duration_ms required, <= 120 s
TranscribeService Deepgram pre-recorded API
refuse unsupported language (free)
reserve the budget (atomic)
POST audio -> transcript, measured duration, confidence
settle the budget to the measured length
refuse if measured length > 132 s
transcript > 2000 chars? -> 400 message_too_long, transcript in details
SendMessageService(voice: { confidence: }) the same send as typed textLanguages. TranscribeService::MODEL_BY_LANGUAGE and SUPPORTED_LANGUAGES were built by
transcribing real audio against Deepgram, not from Deepgram's model list, which is wrong for some
languages: it lists Hindi under nova-3-medical, but a real call returns 400. English uses
nova-3-medical, and Hindi, Punjabi, Tamil, Telugu and Arabic use nova-3-general. Malayalam
is not supported: nova refuses it, and whisper (the only model family that covers it) is not
enabled on our Deepgram plan. Before adding a language, transcribe real audio with it first. Keep
VOICE_LANGUAGES in the web app in step with SUPPORTED_LANGUAGES.
Spend. Deepgram charges per minute of audio, and the server cannot measure a recording's
length without decoding it (the image has no ffprobe). So:
- a byte cap (4 MB) and a required declared length (120 s);
- a budget of 15 minutes of audio per acting user per hour.
RESERVE_SECONDSis held before the call and replaced with the measured length afterwards, or refunded if the call fails. The hourly key is fixed at first use, so a call that crosses the hour settles in the same bucket. Silent audio is charged, because Deepgram charges for it. The budget caps the hour, not a single call: one 4 MB upload of compressed audio can be far longer than 120 s, and the measured-length refusal below stops it becoming a note only after Deepgram has charged for it; - a recording measured longer than
MAX_MEASURED_SECONDSis refused rather than saved as a note.
The budget counter is written with raw: true, for the reason in section 7.
No audio is kept by us. Rack spools the upload to a temporary file for the length of the
request; it is read, posted to Deepgram and dropped. Requests send mip_opt_out=true, so Deepgram
does not keep the audio for model training. Deepgram's own processing retention is governed by
the provider agreement, not by this code.
5. Reading, paging and folders
GET .../chat/threads/:thread_id/messagespages by cursor.direction=beforereturns older messages, anddirection=afterreturns newer ones.meta.cursoris always the edge of the page nearest the next request in that direction. A cursor that is not a uuid returns400 validation_error.- Folders are addressed by slug for the defaults and by uuid for the patient's own.
Folder.resolveaccepts either. An unknownfolder_idon a thread returns 404; it does not quietly remove the thread from its folder. - Deleting a folder moves its threads and entries to General in one transaction. Default folders cannot be deleted.
GET /summary?days=Ncalls the model when the cache misses. Only call it when the patient asks for a summary.
6. Authorization and caregivers
Every action goes through Pundit with Patient::OwnedPolicy, named once as
HealthDiary::BaseController::DIARY_POLICY (authorize_diary, diary_scope). pundit_user is
subject_user, so when a caregiver acts for a dependent through X-On-Behalf-Of, ownership is
checked against the dependent. The rule that the caregiver may act for that dependent is checked
earlier, when the subject is resolved.
Another patient's row always returns 404, never 403: a 403 would confirm the row exists.
The pattern that guarantees it: find through the subject first, then authorize. Look the row
up with Model.for_user(subject_user).find_by(id:) (or Folder.resolve(subject_user, id)) and
render 404 on nil; only then call authorize_diary on the row you found. Never
Model.find(id) followed by authorize, which turns someone else's id into a 403.
ownership_test.rb keeps that true.
Shared::Concerns::AuthorizeCoverage fails any request test that reaches an action without
authorize or policy_scope. It only raises in test, which is how the diary once shipped with no
authorization at all. Add a request test for every new action.
7. Rate limits
| Budget | Limit | Declared as |
|---|---|---|
| Sending, text and voice combined | 30 per hour | rate_limit %i[create voice], ..., name: 'health_diary#send' |
| Summary (a model call) | 30 per hour | SEND_LIMIT |
| Everything else | 100 per hour | READ_LIMIT |
Limits are counted per acting user, so a caregiver has one allowance, not one per dependent.
Use the SEND_LIMIT and READ_LIMIT constants. limit_for_current_action reads the same
constants, which keeps the X-RateLimit-* headers accurate.
RateLimitable (backend/parts/core/controllers/concerns/rate_limitable.rb) is shared across the
app. Two things about it:
name:shares one counter between actions. Without it, eachcontroller#actiongets its own counter.- The counter must be written with
raw: true. Production and staging useRedisCacheStore. It serializes a non-raw value, soINCRBYfails, the error handler swallows the failure, and the count resets to 1 on every request. That bug disabled every limit in the app until #2447.MemoryStorehides it, so any limiter test that matters should run on Redis, asrate_limit_redis_test.rband the diary'srate_limit_test.rbdo.
8. Model configuration
HealthDiary::ModelSettings picks the provider and model, in this order:
HEALTH_DIARY_PROVIDER/HEALTH_DIARY_MODELenvironment variableshealth_diary.provider/health_diary.modelin credentials- the platform's
ai_provider/ai_modelcredentials - whichever key is configured, OpenRouter first; the default model is
gpt-4, prefixedopenai/for OpenRouter
Deepgram reads deepgram.api_key, then the older top-level deepgram_api_key, from credentials.
Model calls go through Shared::LlmCallService with HealthDiary::AuditSink, which writes
health_diary_llm_call_logs instead of the clinical audit table. That table requires a tenant,
and a diary call has none. Costs are recorded only for models with a known price; an unknown cost
is left empty rather than estimated.
9. The web app
patient/src/features/health-diary/
pages/HealthDiary.tsx layout, URL state (?thread ?folder ?tab ?tags), subject switch
hooks/useDiaryQueries.ts queries and mutations; useSendMessage, useSendVoiceMessage
hooks/useVoiceRecorder.ts MediaRecorder, the 120 s cap, cleanup
components/ Conversation, Composer, FolderStrip, ThreadList, EntryList,
VisitHandout, HealthDiaryCard (dashboard), DiagnosticsDrawer
services/health-diary.service.ts the API calls and the fixture switch
services/diagnostics/ dev-only request recorder
services/fixtures/ contract-shaped fixtures and failure scenariosSending. useSendMessage exposes send() for a new note and retry() for a failed one. A new
note always gets a new key. A retry reuses the failed note's key and thread, so the server resumes
it. The key and the subject are fixed when the note is sent and carried on the mutation's own
variables. Do not read them later from a ref or from the live store, because by then the
caregiver may have switched to someone else.
Per-diary state. The pending and failed note are kept per subject, in one state object,
and each mutation callback only changes the entry that is still its own send. A failure is only
recorded while that send is still the diary's current one. The page shows only the active
subject's entry, and awaitingReply is scoped to the active subject too. The composer has
key={subjectId}, so switching diaries clears the typed text and any recording.
The failed card. The failed note carries the DiaryError it failed with, and the card shows
that error's message rather than fixed text. The server's messages only say "Your note is saved"
when it was (ai_unavailable, internal_error, and content_moderated for a withheld reply).
No response at all maps to network_error, which claims neither. isRetryable withholds
Try again for content_moderated, message_too_long, validation_error and
idempotency_conflict, where the same words fail the same way.
Resuming after a reload. The in-memory failed send dies with the page, so the note itself
carries the failure: the messages list marks the patient's own row reply_failed (computed in
MessagesController#failed_reply_ids, one query per page) and returns its language.
failed_reply_ids must match what SendMessageService actually resumes -- a failed reply and
a pending one older than STALE_PENDING_AFTER. Only the owning request can mark a reply failed,
so a process killed mid-call (deploy, OOM) leaves the row pending; matching failed alone left
those notes with no answer and no retry, which is the bug the flag exists to fix. resume pins the
stored note's language next to its content, so a client that does not echo the field back cannot
turn a Hindi note into an English answer.
useSendMessage's resume(message) re-sends that note under its own client_message_id, which
the server's replay path turns into a resume rather than a second note. The optimistic copy is
suppressed while it runs, because the server already holds the row.
Printing one region. printRegion() puts printing-region on <html> for the length of
window.print(), and globals.css has the matching @media print block: everything is hidden,
the element with data-print-region and its subtree are shown, and :has() neutralises the
scroll containers above it. Hiding parts of the shell individually was the earlier approach and
printed the whole page, because each min-h-0 flex-1 overflow-y-auto box prints only what is
scrolled into view. Nothing applies unless a region asks for it, so Ctrl+P elsewhere is
unchanged. Verify with a real print preview; a unit test can only prove the class goes on and
comes off.
Timeouts. Sends, voice and summaries set their own axios timeouts (120 s, 220 s, 120 s) instead of the app-wide 10 s. They must stay above the server's worst case: 90 s for the model, plus 10 s + 45 s for Deepgram on voice. If you change a server timeout, change these too.
Voice. useVoiceRecorder gives each recording its own clientMessageId when it is captured.
The composer only releases a recording once it has been sent, so a failed send keeps it for
another try. The recorder releases the microphone and the blob: URL on discard, on success and
on unmount, including when the page is left while the permission prompt is still open.
Diagnostics drawer. It exists only in a development build, behind import.meta.env.DEV. A
production build contains none of its code; check with pnpm build and a grep of dist/. It
records request metadata only, never tokens and never message text (text is kept as its length).
Copy-as-bug-report contains only the path plus the thread and tab parameters.
Fixtures. Set VITE_HEALTH_DIARY_MOCK=true to run against fixtures. ?scenario= (or
VITE_HEALTH_DIARY_SCENARIO) selects a failure case: empty, rate_limited, ai_unavailable,
moderated, emergency, slow, transcription_failed. Mock mode is always off in a production
build.
10. Testing
# backend -- use your own test database (the shared kulcare_test collides across worktrees)
cd backend
DB_NAME_TEST=kulcare_test_<you> PARALLEL_WORKERS=1 bundle exec rails test \
domains/patient/test/requests/health_diary/ domains/patient/test/services/health_diary/
# web app
cd patient && pnpm vitest run src/features/health-diary && pnpm tsc -b && pnpm build- The rate-limit tests need Redis on
localhost:6379(orREDIS_URL). CI provides it. They fail, not skip, without it. - No test calls a real model or Deepgram.
Shared::LlmCallServiceandTranscribeService#post_audioare stubbed at those boundaries. Keep it that way. - The test database's
test:block inconfig/database.ymlmust useusername: kulcare_fixtures. Withkulcare, the schema reload fails onCREATE EXTENSIONand leaves an empty database, and every test then fails with "relation does not exist". - For each defect you fix, write a test that fails without the fix. Several tests in this feature were checked that way. A test that cannot fail gives false confidence.
11. Common changes
| To... | Change | And keep in step |
|---|---|---|
| Add a voice language | MODEL_BY_LANGUAGE / SUPPORTED_LANGUAGES after transcribing real audio | VOICE_LANGUAGES (web), the mobile guide's table |
| Add a CTA action | Cta::ACTIONS, CtaBuilder::LABEL_KEYS + FALLBACK_LABELS, health_diary.ctas.* | CTA_ROUTES (web); unknown actions are hidden in the web app |
| Add an error code | the controller | DiaryErrorCode + FALLBACK_COPY (web), the mobile guide's error table |
| Change a limit | SEND_LIMIT / READ_LIMIT | the mobile guide's rate-limit table |
| Change a timeout | the service | the web timeouts in health-diary.service.ts |
12. Open decisions
- U10 safety: a clinician-reviewed emergency term list and a moderation provider.
- F8 realtime: live refetch needs backend notification work.
- D-1: the response envelope, and the app's language allowlist (
en, hi, es) against the diary's seven languages. Both need the mobile team. - Malayalam voice: needs whisper enabled on the Deepgram plan.
- The web app has no language picker.
HealthDiary.tsxsendslanguage: 'en'for every send, so web voice notes are always transcribed as English. Typed text in other languages still works, because the model reads it, but our own added text is English. Building a picker depends on D-1's language list. - The privacy section of the user guide does not name the processors. Typed text goes to the model provider and audio to Deepgram. The disclosure wording is a product and legal decision.
- Deleting a conversation does not delete its entries. Entries and any cached summary stay. The user guide says so; whether delete should cascade is a product decision.