Skip to main content

Ruby for Reception — Cockpit Design

Date: 2026-05-23 Status: Spec Author: ssh Reference tenant: Dental Square (dfffc93f-82eb-47fd-a276-b79c24dccb80)

1. Problem

Dental Square reception handles 20–40 appointments/day (ramping toward 100+) on one receptionist with every automation switch off. Diagnostic snapshot (last 90 days):
SignalValueWhat it means
Appointments101Volume real, ramping
missed status40 (39.6%)Massive no-show drag
completed status0Reception never closes visit loop
Doctor assigned1 of 101Slots booked without doctor binding
Room assigned0 of 101Operatory tracking unused
doctor_schedules rows0No availability windows defined
WhatsApp automations enabled0No auto-confirm, no reminders, no recall
patient_recalls rows0No recall pipeline
saved_replies rows0No message templating
bulk_messages rows0No batch outreach
Operating hours13:00–21:30 Mon–SatEvening clinic, late peak
Most needed features already exist in OdontoX (whatsapp_automations, patient_recalls, doctor_schedules, saved_replies, clinic_modules.ai_insights = true). The gap is leverage: the receptionist has no surface that pulls the data into a single action queue, and there’s no Ruby narration layer making it readable on a busy floor. WhatsApp send is not yet integrated for Dental Square — surfaces that depend on WA send are Phase 2.

2. Architecture

Hard rule: deterministic SQL decides who/what/when. Ruby only writes the words.
                      Receptionist UI

              ┌─────────────┴──────────────┐
              │  Reception Cockpit (React) │
              └─────────────┬──────────────┘


              ┌───────────────────────────┐
              │   Surface endpoints       │
              │  (Hono routes /reception) │
              └─────────┬─────────────────┘

        ┌───────────────┴────────────────┐
        ▼                                ▼
┌──────────────────┐          ┌──────────────────────┐
│ Deterministic    │          │ Ruby narrator        │
│ SQL aggregator   │ ───────▶ │ (deepseek-v4-flash   │
│ (Drizzle, indexes│          │  via Langfuse)       │
│ on clinic+date)  │          │                      │
└──────────────────┘          └──────────────────────┘
        │                                │
        ▼                                ▼
  facts, ids, scores            paragraphs, drafts,
  (cacheable per clinic)        classifications
Why this split:
  • Deterministic SQL is fast, cheap, free, and fully predictable — perfect for “who needs follow-up” decisions.
  • Ruby is good at narration, tone, classification, summarization — bad at deciding who to message.
  • Ruby never reads PHI without a deterministic gate (consistent with existing decryptPatientPHI pattern in appointment-nudges.ts).

3. Model & Cost

Lock: deepseek-v4-flash (replaces current deepseek-chat / V3.2 default). Why:
  • 1M context (was 128K) — full patient history + clinic config fits with room
  • 0.14/Minputcachemiss,0.14/M input cache miss, **0.0028/M cache hit** (50× cheaper)
  • 0.28/Moutput(was0.28/M output (was 0.40)
  • Native JSON mode + tool calls
  • response_format: { type: 'json_object' } supported as today
Migration: change default in server/src/lib/ai/client.ts line 64 from 'deepseek-chat' to 'deepseek-v4-flash'. DEEPSEEK_MODEL env var continues to override per-environment. DICOM is untouched. server/src/lib/ai/agents/dicom-analysis.ts uses gpt-5.4-mini via OpenAI and is out of scope. Cost guardrail (40-appt/day clinic, all 4 Phase-1 surfaces):
  • Morning brief: 1 call/day, ~2K input + ~500 output = $0.0004
  • Prep hints: 40 calls/day, mostly cache hits on system prompt (~3K stable + ~200 variable input) + ~80 output ≈ $0.001 total
  • Patient snapshot: ~20 lookups/day, ~1K input + ~150 output = $0.003
  • EOD reconciliation: 1 call/day, ~2.5K input + ~400 output = $0.0005
  • Total: < $0.01/day per clinic at steady state. Pennies.
Cache strategy (critical): DeepSeek caches input prefixes automatically. To maximize hit rate:
  • System prompt stays byte-identical across calls of the same surface (managed by Langfuse, fetched once per worker isolate via 300s TTL cache)
  • User message starts with a stable schema-reminder header, then the per-call JSON payload
  • Track prompt_cache_hit_tokens / prompt_cache_miss_tokens in Langfuse metadata for cost monitoring

4. Prompt Management — Langfuse

No hardcoded prompts. Every Ruby surface fetches its system prompt from Langfuse via getPrompt(name, undefined, { cacheTtlSeconds: 300 }). Fallback strings in prompts.ts stay as last-resort failsafe only (existing behavior).
SurfaceLangfuse namePrompt key (PROMPT_NAMES)New?
Morning Briefreception-morning-briefreceptionMorningBrief✅ NEW
Today’s Queue prep hintreception-prep-hintreceptionPrepHint✅ NEW
Patient Snapshotreception-patient-snapshotreceptionPatientSnapshot✅ NEW
EOD Reconciliationreception-eod-reconcilereceptionEodReconcile✅ NEW
Follow-Up Drafter (Phase 2)reception-followup-drafterreceptionFollowupDrafter⏳ deferred
Triage Classifier (Phase 2)reception-triage-classifierreceptionTriageClassifier⏳ deferred
Existing prompts that reception already benefits from (no edit needed, just re-verify after model swap):
  • appointment-nudges — reused by Today’s Queue badge logic
  • payment-reminder-msg — used when reception clicks “draft balance reminder”
  • patient-recall-msg — used by EOD Reconciliation when a recall is overdue
  • daily-clinic-brief — owner-facing daily brief; distinct from reception morning brief
Push procedure:
  1. Add new prompts to server/src/lib/ai/prompts.ts (PROMPTS + PROMPT_NAMES)
  2. Run npx tsx server/src/scripts/sync-prompts-to-langfuse.ts (existing script)
  3. Verify with --dry-run first, then push for real
  4. Spot-check in Langfuse dashboard: each prompt should be on label production, version bumped
Prompt authoring rules (all 4 new prompts):
  1. Three-tier structure: System role + JSON schema + Rules (matches existing house style)
  2. The word “json” must appear in the system prompt (DeepSeek JSON-mode requirement)
  3. Concrete JSON example baked in
  4. Ruby brand, never “AI” or “Action Items”
  5. PKR with commas
  6. No emojis in patient-facing output
  7. Empty array / empty string preferred over fabrication
  8. Stable text — never embed per-call data in the prompt template; that comes via user message

5. Phase 1 surfaces (ship now)

5.1 Morning Brief

Surface: Top of Reception dashboard, replaces the generic “Good morning” greeting card. When: Once per day per clinic, on first dashboard load after 05:00 PKT. Cached in tenant_daily_briefs table for the rest of the day. Deterministic input shape (user message JSON):
{
  "date": "2026-05-23",
  "dayName": "Saturday",
  "receptionistName": "Aisha",
  "appointments": {
    "totalToday": 38,
    "confirmed": 12,
    "unconfirmed": 26,
    "firstVisits": 4,
    "withBalance": 7,
    "highNoShowRisk": 3,
    "unassignedDoctor": 38,
    "topRiskPatients": [
      { "first": "Ahmed",  "time": "17:30", "reason": "missed last 2 visits"  },
      { "first": "Fatima", "time": "19:00", "reason": "4-week-out booking, no confirm"  }
    ]
  },
  "balances": { "expectedCollections": 42000, "patientsOwing": 7, "topOwed": [ { "first": "Sara", "balance": 18000 } ] },
  "recalls": { "dueToday": 2, "overdue": 5 },
  "operations": {
    "doctorsAvailable": 1,
    "doctorScheduleGaps": ["No schedule defined for Dr. Khan — assignment will fail"],
    "inventoryLow": 0
  }
}
Ruby output (JSON):
{
  "greeting": "Good evening, Aisha — busy Saturday ahead.",
  "headline": "38 appointments today, 26 still unconfirmed. Priority: lock confirmations before 16:00.",
  "priorityList": [
    { "action": "Call 26 unconfirmed patients", "deepLink": "/dashboard?view=appointments&status=scheduled", "rationale": "26 unconfirmed booked for tonight" },
    { "action": "Watch for Ahmed (17:30)", "deepLink": "/dashboard/appointments/<id>", "rationale": "missed last 2 visits — confirm by SMS first" },
    { "action": "Collect PKR 18,000 from Sara at desk", "deepLink": "/dashboard?view=finance-invoices", "rationale": "balance from prior visit" }
  ],
  "operationalAlerts": ["38 appointments have no doctor assigned — pick a doctor when checking in or auto-assign to Dr. Khan"]
}
Risk scoring (deterministic, feeds the brief):
no_show_risk = clamp(
    0.40 * (per_patient_missed_90d / per_patient_appts_90d * 100)
  + 0.25 * (days_since_booked >= 14 ? 1 : 0) * 100
  + 0.15 * (first_visit ? 1 : 0) * 100
  + 0.10 * (outstanding_balance > 0 ? 1 : 0) * 100
  + 0.10 * (late_arrivals_last_3 * 33)
, 0, 100)
High risk ≥ 60. Computed in SQL. Ruby narrates “missed last 2 visits”, not “risk score 78”.

5.2 Today’s Queue prep hints

Surface: Inline badge under each appointment row in today’s list. Hover/tap reveals 1-sentence prep hint. When: On dashboard load. Batch all today’s appts in a single Ruby call (cache-hit friendly — same system prompt, just a different per-call user payload). Deterministic input (user message JSON):
{
  "appointments": [
    { "id": "appt_xx", "first": "Ahmed", "time": "17:30", "type": "Root Canal",
      "isFirstVisit": false, "hasConsent": true, "balanceOwed": 0, "doctorAssigned": false,
      "roomAssigned": false, "noShowRisk": 78, "lastVisit": "2026-04-19", "lastProcedure": "Filling 16M" }
  ]
}
Ruby output:
{
  "hints": [
    {
      "appointmentId": "appt_xx",
      "tone": "warn",
      "hint": "Likely no-show — confirm by SMS now. Continuing RCT on #16; assign a doctor before chair time.",
      "flags": ["no-doctor", "no-room", "high-risk"]
    }
  ]
}
tone: "ok" | "info" | "warn" | "urgent" (drives badge color).

5.3 Patient Snapshot

Surface: Side drawer on patient row click. 4 lines, 15-second read. Reuses existing patient-brief-summary prompt at first — but with a reception-specific user payload that emphasizes today’s relevance (balance owed, what’s due today, allergies that matter for today’s procedure type). No new prompt needed if existing one works; if reception variant needed, branch as reception-patient-snapshot. Decision: Start with existing patient-brief-summary. Re-evaluate after 2 weeks; only fork to a new prompt if reception explicitly asks for a different framing. Phase 1 only authors 3 new prompts (reception-morning-brief, reception-prep-hint, reception-eod-reconcile). Snapshot uses existing.

5.4 EOD Reconciliation

Surface: Modal triggered at clinic close time (configurable; default 30 min before operating_hours.end) or via “Wrap up day” button. When: Once per day. Stored in tenant_daily_briefs table or a new reception_eod_logs row (TBD in plan). Deterministic input:
{
  "date": "2026-05-23",
  "unclosedConfirmed": [
    { "id": "appt_a", "first": "Hira",  "time": "15:00", "type": "Check-up" }
  ],
  "completedNoInvoice": [
    { "id": "appt_b", "first": "Bilal", "type": "Crown",  "estimatedFee": 25000 }
  ],
  "missedToday": [
    { "id": "appt_c", "first": "Zara",  "time": "16:30", "phoneOnFile": true }
  ],
  "balancesCollectedToday": 12000,
  "balancesExpectedToday": 42000
}
Ruby output:
{
  "summary": "3 visits still in confirmed — looks like Hira's check-up never got closed. 1 completed visit (Bilal, Crown) hasn't been invoiced. 1 patient missed and is reachable by WA.",
  "batchActions": [
    { "label": "Mark 3 visits as completed",     "endpoint": "POST /reception/eod/close-confirmed", "ids": ["appt_a", "..."], "tone": "info" },
    { "label": "Generate invoice for Bilal (Crown)", "deepLink": "/dashboard/appointments/appt_b#invoice", "tone": "warn" },
    { "label": "Send recovery message to Zara",  "deepLink": "/dashboard/appointments/appt_c#recover",   "tone": "info", "disabledReason": "WhatsApp send not yet configured" }
  ],
  "tomorrowSetup": "26 appointments tomorrow already unconfirmed — schedule a 09:00 call block."
}

6. Phase 2 surfaces (when WA send lands)

  • Follow-Up Drafter (reception-followup-drafter): given a list of patients flagged by deterministic SQL (missed today, unconfirmed tomorrow, recall due, outstanding balance), Ruby drafts per-patient WA message with reschedule slot suggestions. Deterministic layer provides the slots.
  • Triage Inbox (reception-triage-classifier): inbound WA messages classified (booking-request / question / complaint / no-action) with suggested 1-click replies. Confidence threshold below 0.7 → human review.
  • D from prior brainstorm = subset of Follow-Up Drafter (the “missed today” cohort).
These prompts authored only when WA integration ships. Schema slots reserved in PROMPT_NAMES now.

7. Data model changes

None required for Phase 1. All needed surfaces query existing tables:
  • appointments (clinic_id + appointment_date indexed)
  • invoices (balance + status)
  • patients (last_visit derived)
  • tenant_daily_briefs (already exists for daily brief caching)
  • clinic_operating_hours / clinics.operating_hours
Phase 2 may add reception_eod_logs if we want to store the EOD reconciliation history. Not blocking.

8. API surface (Phase 1)

New Hono routes under server/src/routes/reception.ts (new file):
MethodPathPurpose
GET/reception/morning-briefReturns cached or freshly-generated brief for today
POST/reception/morning-brief/refreshForce regenerate (rate-limited 1/hour per clinic)
POST/reception/prep-hintsBody: { appointmentIds: string[] } — returns hint per id
GET/reception/eodReturns EOD reconciliation payload
POST/reception/eod/close-confirmedBatch mark confirmedcompleted
All routes:
  • Require receptionist, admin, or doctor role
  • Clinic scope enforced via getClinicIdFromContext
  • PHI decrypted server-side, never sent raw to Ruby (deterministic aggregator already handles this in appointment-nudges.ts — copy the pattern)

9. UI surface (Phase 1)

Extend, don’t replace ui/src/components/receptionist/ReceptionistOverview.tsx:
  1. MorningBriefCard — new component at top, above existing KPI cards. Collapsible. Shows greeting + headline + priority list with deep links.
  2. Today’s appointments table — add a new Prep column rendering the badge + hover tooltip with the hint. Existing rows untouched.
  3. EOD Reconciliation button — new “Wrap up day” CTA in the header. Opens a modal with the Ruby summary + batch action buttons.
  4. Patient drawer — keep using existing patient brief endpoint (no change in Phase 1).
All Ruby cards branded with Ruby logo per the brand rule. Loading states use the existing MetricSkeleton and a Ruby-specific shimmer.

10. Eval & observability

Langfuse:
  • All Ruby calls already traced via client.ts — extend metadata to include surface (“morning-brief” | “prep-hint” | “eod”) and cacheHitRatio (computed from prompt_cache_hit_tokens)
  • Per-surface dashboards in Langfuse for cost, latency, p99 token count
  • Eval dataset: collect 50 Dental Square brief renders over 1 week, human-rate on a 1-5 scale, set up Langfuse experiments to compare prompt versions
Failure modes & fallback:
  • DeepSeek returns empty content (documented failure) → retry once; if still empty, show deterministic-only fallback (“38 appointments today, 26 unconfirmed” — no narration)
  • Langfuse getPrompt throws → fall through to hardcoded prompt in prompts.ts (existing pattern)
  • Ruby JSON parse error → log to worker_logs, render deterministic fallback
No silent degradation: every fallback emits a worker_logs row tagged ruby_fallback so we know when Ruby is sad.

11. Migration plan

  1. Push 3 new prompts to Langfuse (reception-morning-brief, reception-prep-hint, reception-eod-reconcile)
  2. Switch model default in client.ts to deepseek-v4-flash
  3. Re-push existing prompts to ensure they’re current on the new model context (no edits, just version bump): daily-clinic-brief, patient-brief-summary, appointment-nudges, payment-reminder-msg, patient-recall-msg, churn-reengagement-msg, treatment-followup-msg, revenue-forecast-insight, monthly-performance-summary, clinical-notes-soap, note-rewrite, note-grammar-check, note-shorten, note-expand, treatment-plan-presentation, superadmin-tenant-daily-brief. Skip DICOM — uses GPT, not DeepSeek.
  4. Smoke test on Dental Square: trigger morning brief, render today’s queue, run EOD reconciliation. Confirm Langfuse traces show prompt_cache_hit_tokens > 0 after second call.
  5. Build endpoints + UI per Section 8–9.
  6. Ship behind feature flag reception_cockpit_v1 on clinic_modules. Roll out to Dental Square first; observe 1 week; then enable for other reception-using clinics.

12. Scope this spec covers

  • 3 new Langfuse prompts (authored + pushed)
  • 16 existing prompts re-pushed (untouched content, version bump)
  • Model default flip to deepseek-v4-flash
  • Spec for Phase 1 surfaces (Morning Brief, Prep Hints, EOD Reconciliation; Patient Snapshot reuses existing)

13. Scope this spec does NOT cover

  • DICOM prompt or model (GPT-based, untouched)
  • WhatsApp send integration
  • Follow-Up Drafter and Triage Inbox prompts (Phase 2)
  • Reception cockpit UI build-out (separate plan via writing-plans)
  • API route implementation (separate plan via writing-plans)
  • Smart booking (C from prior brainstorm — explicitly out of scope per user)

14. Risks

RiskLikelihoodMitigation
deepseek-v4-flash not yet on our DeepSeek accountMediumSmoke test before merging the default swap; keep deepseek-chat env override available
Cache hit rate stays low (system prompts drift)LowLint check that Langfuse prompts don’t contain dates/IDs
Ruby fabricates patient namesLowDeterministic layer is the single source of truth; Ruby reads decrypted names only after aggregator decides who to mention
Receptionist trusts Ruby blindlyMediumEvery Ruby card has the Ruby logo + a “deterministic count: N” footnote so the source is visible
Cost overrunsVery lowCost ceiling is pennies/day; Langfuse dashboards alert at $1/day per clinic

15. Open questions

None. Spec is ready to feed into writing-plans for the implementation plan.