Kulcare Docs
DeveloperHealth Diary

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:

HalfWhereRead first
Backendbackend/domains/patient/**/health_diary/ (module Patient::HealthDiary)services/health_diary/send_message_service.rb
Web apppatient/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.

  1. 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.
  2. Every answer carries the disclaimer, and an emergency answer starts with guidance. Our code adds both. The model cannot remove them.
  3. There is always a route to care. Every answer offers "See Care Options", whatever the model suggests.
  4. One person's diary never shows in another's. This holds for patients and for caregivers switching between the people they manage.
  5. 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*.

TableHolds
health_diary_foldersThe patient's folders. Four defaults (general, diabetes, back-pain, personal) are created on first use.
health_diary_threadsConversations. Each belongs to at most one folder.
health_diary_messagesBoth sides of each exchange. role is user or assistant.
health_diary_ctasThe next-step buttons on an assistant message.
health_diary_entriesThe diary entry made from each exchange, with tags.
health_diary_summariesCached "what to tell your doctor" summaries, one per period.
health_diary_llm_call_logsCost 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 has recorded_by_user_id, the person who typed it, which differs from user_id when a caregiver writes for a dependent.
  • These tables are not Patient::Owned. That concern adds a source provenance column (self_reported, clinical_imported, ...), which means nothing for a chat message. HealthDiary::DiaryOwned keeps the parts that apply: ownership, the subject and actor pair, and soft delete. domains/patient/test/foundation_test.rb excludes 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_id links each assistant message to the patient message it answers. The replay logic depends on it; do not look replies up by position.
  • client_message_id is 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, tagged

3.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 stateResult
completeThe original exchange is returned. Nothing is sent to the model.
pending, updated in the last 3 minutes409 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 words409 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.disclaimer

CtaBuilder 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 text

Languages. 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_SECONDS is 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_SECONDS is 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/messages pages by cursor. direction=before returns older messages, and direction=after returns newer ones. meta.cursor is always the edge of the page nearest the next request in that direction. A cursor that is not a uuid returns 400 validation_error.
  • Folders are addressed by slug for the defaults and by uuid for the patient's own. Folder.resolve accepts either. An unknown folder_id on 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=N calls 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

BudgetLimitDeclared as
Sending, text and voice combined30 per hourrate_limit %i[create voice], ..., name: 'health_diary#send'
Summary (a model call)30 per hourSEND_LIMIT
Everything else100 per hourREAD_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, each controller#action gets its own counter.
  • The counter must be written with raw: true. Production and staging use RedisCacheStore. It serializes a non-raw value, so INCRBY fails, 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. MemoryStore hides it, so any limiter test that matters should run on Redis, as rate_limit_redis_test.rb and the diary's rate_limit_test.rb do.

8. Model configuration

HealthDiary::ModelSettings picks the provider and model, in this order:

  1. HEALTH_DIARY_PROVIDER / HEALTH_DIARY_MODEL environment variables
  2. health_diary.provider / health_diary.model in credentials
  3. the platform's ai_provider / ai_model credentials
  4. whichever key is configured, OpenRouter first; the default model is gpt-4, prefixed openai/ 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 scenarios

Sending. 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 (or REDIS_URL). CI provides it. They fail, not skip, without it.
  • No test calls a real model or Deepgram. Shared::LlmCallService and TranscribeService#post_audio are stubbed at those boundaries. Keep it that way.
  • The test database's test: block in config/database.yml must use username: kulcare_fixtures. With kulcare, the schema reload fails on CREATE EXTENSION and 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...ChangeAnd keep in step
Add a voice languageMODEL_BY_LANGUAGE / SUPPORTED_LANGUAGES after transcribing real audioVOICE_LANGUAGES (web), the mobile guide's table
Add a CTA actionCta::ACTIONS, CtaBuilder::LABEL_KEYS + FALLBACK_LABELS, health_diary.ctas.*CTA_ROUTES (web); unknown actions are hidden in the web app
Add an error codethe controllerDiaryErrorCode + FALLBACK_COPY (web), the mobile guide's error table
Change a limitSEND_LIMIT / READ_LIMITthe mobile guide's rate-limit table
Change a timeoutthe servicethe 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.tsx sends language: '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.

On this page