medOS ultra

Navigation Next-Best-Action

Navigation recommender: telemetry table, n-gram pattern MV, abstain-first suggestion RPC, feedback log, fail-open frontend hook.

13 min read diagramsUpdated 2026-05-30docs/architecture/navigation-next-best-action.md

Date: 2026-05-29 Author: Claude (design session) Scope: Behavior-driven “next step / next tab” nudges in patient-profile and unified-action surfaces, with a future form-autofill tier Status: Design only — not built. Extends the existing AI recommendation engine; adds one genuinely new substrate (UI-interaction capture).


1. The Idea (and the key reframe)

While a clinician works an encounter, watch how they actually move through the UI — open a tab → scroll → finish → move on — and at the natural seams offer a quiet nudge: “you usually open Nursing Assessment next.” Later, extend the same engine to pre-fill form fields from RAG’d patterns. Every suggestion is recommend-only, human-confirms, dismissible.

This is not a new architecture. The repo already has a 5-domain AI recommendation engine (docs/architecture/ai-recommendation-engine-validation.md, reference impl services/vision/.../medicationPlanner). It already gives you: the 3-tier ranking signal (your history > department pattern > hospital pattern), the MV → RPC → Ollama-enrich → audit-log template, on-prem Ollama, and — crucially — Domain 5 (“Department Quick-Picks”) which is pure SQL frequency, no LLM. Navigation is just Domain 6, built from that same template.

What’s genuinely new: the existing engine mines clinical content (orders, diagnoses, prescriptions). Navigation mines UI behavior (tab opens, scroll, dwell, completion). That stream doesn’t exist yet — capturing it is the one new substrate this doc adds.


2. Reuse vs. New

Concern Source Reuse or New
3-tier ranking (you > dept > hospital) get_prescription_suggestions RPC pattern Reuse (re-parameterize for nav)
MV → RPC → LLM-enrich → audit-log skeleton medicationPlanner template Reuse
No-LLM pure-frequency tier Domain 5 “Dept Quick-Picks” Reuse (this IS the safe Tier-1 nudge)
On-prem Ollama (mistral:7b) live on PH demo Reuse
Config-as-data thresholds policy_gates / cds_rules / detection_rules JSON-predicate shape Reuse
Coarse behavioral events RUDS user_action_events (server-side audit) Partial — too coarse for nav
Fine-grained UI capture (tab/scroll/dwell/dismiss) NEW (ui_interaction_events)
Encounter-aware context key + backoff NEW (this doc, §6)
Abstain-first gate ladder + prompt NEW (this doc, §8–9)
Form-autofill tier unified-clinical-assistant.md FORM-FILL class + forms_registry Reuse (Tier 2, §12)

3. The Two Hard Requirements (and how they reconcile)

R1 — Each encounter type yields different navigation. A nurse in ER trauma navigates nothing like the same nurse in an OPD follow-up, an IPD admission, a delivery-room case, or an NM appointment. So encounter_class/subtype is a first-class context dimension, and prediction is fundamentally a sequence problem (“given the last k things you did in this kind of encounter, what’s next”).

R2 — Don’t be annoying when there isn’t enough data. Silence is the default. A nudge must clear an explicit, configurable evidence bar before it shows.

These pull against each other: the more you specialize the key for R1, the fewer samples per exact key → everything goes silent (curse of dimensionality). The reconciliation is n-gram-style backoff (§6): try the specific key, fall back to coarser keys until one clears the support threshold; if even the coarsest fails, stay silent. Collect wide, mine narrow with backoff.


4. Data Capture — “Collect All Data”

4.1 Why a new table (not just user_action_events)

user_action_events is server-side audit grade — API calls, logins, resource mutations. UI micro-events (tab focus, scroll depth, dwell, dismiss) are client-side, far higher volume, and a different lifecycle. Folding them into the security audit stream would drown RUDS in noise.

Decision: a sibling hypertable ui_interaction_events that follows the same envelope conventions, with aggressive retention/downsampling, feeding the miner. Session-summary facts (e.g. “completed encounter workspace in 4 tabs, 6m”) may optionally roll up into user_action_events for the RUDS/security view. This honors “collect all data” without polluting the audit silo.

4.2 Schema (mirrors user_action_events conventions)

CREATE TABLE IF NOT EXISTS ui_interaction_events (
  event_id        UUID        NOT NULL DEFAULT gen_random_uuid(),
  occurred_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  user_id         UUID,
  session_id      UUID,
  role            TEXT,                 -- denormalized for fast mining
  -- ── context dimensions (the R1 axes), denormalized at capture time ──
  tenant_id       UUID,
  department_id   TEXT,
  dept_type       TEXT,                 -- opd / ipd / er / or / lab / pharmacy / delivery / nm ...
  encounter_id    TEXT,
  encounter_class TEXT,                 -- OPD / IMP / EMER / SS ...
  encounter_subtype TEXT,              -- trauma / anc / dialysis / delivery / ...
  journey_state   TEXT,                 -- workflow node from encounter_journey_cache
  acuity          TEXT,                 -- esi_level / priority bucket
  insurance_scheme TEXT,               -- nhso / sso / philhealth / kaigo / cash (optional)
  shift           TEXT,                 -- day / evening / night (off-hours navigate differently)
  -- ── the interaction itself ──
  surface         TEXT NOT NULL,        -- 'patient-profile' | 'medical-worklist' | 'order-system' ...
  element         TEXT,                 -- tab/module id, e.g. 'modules.IpdNursingAssessment'
  interaction     TEXT NOT NULL,        -- tab_open | tab_finish | scroll_depth | dwell | form_open
                                        --   | form_save | action_invoke | nudge_show | nudge_accept | nudge_dismiss
  value           NUMERIC,              -- scroll %, dwell ms, etc.
  prev_element    TEXT,                 -- for sequence/n-gram mining
  metadata        JSONB DEFAULT '{}'::jsonb,
  PRIMARY KEY (event_id, occurred_at)
) PARTITION BY RANGE (occurred_at);

PHI stance: capture identifiers and structure, never clinical free-text or values. element is a module id, not a note body. encounter_subtype/acuity are coarse buckets. No patient name, no lab value, no narrative ever enters this table — which also keeps it safe to feed a prompt later (§9).

4.3 The emitter (frontend)

A thin useInteractionTelemetry() hook + a small dispatcher (batched, navigator.sendBeacon, debounced scroll/dwell). Context dimensions resolve from the already-loaded encounter_journey_cache row + auth/role context — no extra fetches. Mount points: DynamicContentRenderer (tab open/finish/dwell), MainPatientTabs/WorkflowBasedTabs (tab switches), form components (open/save), action handlers (invoke). Capture is fire-and-forget and must never block or error the UI (fail-open).


5. Event Vocabulary (the “all data” we collect)

interaction Fires when Used for
tab_open / tab_finish module/tab gains focus / is left after meaningful dwell sequence mining (the core signal)
scroll_depth debounced scroll milestone (25/50/75/100%) “did they actually read it” / completion proxy
dwell time spent on a surface before leaving weak weight; distinguishes glance vs work
form_open / form_save a form is opened / persisted completion-boundary trigger (Gate 0)
action_invoke a workflow action / button is run cross-links nav patterns to clinical actions
nudge_show / nudge_accept / nudge_dismiss the recommender surfaced / was taken / rejected the feedback loop (Gate 3 + RLHF)

nudge_dismiss is the most valuable row we collect — it’s how the engine learns to shut up.


6. The Context Key + Backoff Hierarchy (the spine)

The prediction target: P(next_element | context, recent_sequence). Context is the high-dimensional tuple from §4.2; recent_sequence is the last k elements (the n-gram prefix).

Because the full key is sparse, the runtime walks a backoff ladder, most-specific → coarsest, and uses the first level that clears support ≥ S_min:

L0  (role, dept_type, encounter_class, encounter_subtype, journey_state, ngram k=2)   ← most specific
L1  (role, dept_type, encounter_class,                     journey_state, ngram k=2)
L2  (role, dept_type, encounter_class,                     journey_state, ngram k=1)
L3  (role,            encounter_class,                     journey_state)
L4  (role,                                                 journey_state)              ← coarsest (hospital-wide)
                                                                                       ↓
                                          if even L4 has support < S_min → STAY SILENT

This is exactly Stupid-Backoff for navigation:

  • R1 satisfied — a well-trafficked encounter type (ER trauma at a busy hospital) resolves at L0/L1, giving encounter-specific predictions.
  • R2 satisfied — a rare encounter type, a new deployment, or a new role finds nothing at L0–L4 and produces no candidates → no nudge. Cold-start handles itself; we never guess from thin air.

Each level blends the 3-tier signal (reused from the existing engine): personal history (this user) over the level’s cohort, with a recency-weighted personalization weight λ that decays toward the cohort when the user has few samples at that level.


7. The Pattern Miner (reuse the template)

Follow the prescription_patterns MV → get_prescription_suggestions RPC → audit-log shape, re-parameterized:

  • navigation_patterns MV — aggregates ui_interaction_events into (context_key_level, ngram_prefix, next_element) rows with counts, refreshed by the existing two-tier RUDS cadence (nightly batch + lighter inline rollup). The batch job is where “not enough data” is decided: rows below S_min are simply not materialized, so the inline path has nothing to show.
  • get_navigation_suggestions(user, context, recent_seq) RPC — walks the backoff ladder, returns ranked candidates with their scores. Pure SQL. This alone powers the Tier-1 nudge with zero LLM.
  • Scoring per candidate (association-rule math, computed in the MV):
    • support(context) — gate input.
    • confidence = P(next | context) — need top ≥ C_min.
    • margin = top − 2nd — ambiguous (many-way tie) ⇒ suppress.
    • lift = P(next | context) / P(next) — ≥ lift_min kills “globally obvious” picks that aren’t actually contextual.
  • navigation_ai_suggestions_log — every show/accept/dismiss (unify under the existing vision_ai_suggestions_log + suggestion_type='navigation' if Open Decision #2 in the validation doc lands that way).

8. The Gate Ladder (cheap → expensive)

The prompt is the second line of defense. The first is not calling the LLM until a deterministic gate passes.

Gate Where Rule Why
0 — boundary client only evaluate on tab_finish / form_save / dwell-past-threshold; never mid-scroll half of “annoying” is timing
1 — sufficiency RPC (SQL, no tokens) backoff finds a level with support ≥ S_min, top confidence ≥ C_min, margin ≥ M_min, lift ≥ lift_min the cold-start killer; LLM never runs when sparse
2 — abstain-first prompt Ollama (only if Gate 1 passed) model re-ranks/phrases pre-scored candidates; may return abstain:true; output confidence < floor ⇒ suppress model can’t invent; silence is modeled as correct
3 — behavioral memory RPC + client per-session frequency cap; dismissed (context, element) ⇒ cooldown/penalty; 2 dismissals ⇒ stop learns to shut up

For Tier-1, Gate 2 is optional — pure SQL (Gates 0/1/3) already gives a safe, useful nudge. The LLM is added only for tie-breaking + natural-language rationale.


9. The Prompt Contract (Tier-1 re-rank)

The model is never the pattern miner — it receives pre-scored candidates and either picks one or abstains.

  • System framing: “You are a workflow-completion assistant. You will receive the clinician’s role, the current encounter context, the recent action sequence, and a ranked list of candidate next actions WITH their historical support/confidence/lift. Recommend exactly ONE, or set abstain:true. Prefer abstaining when no candidate clearly beats the others or evidence is weak. Never propose an action not in the candidate list.”
  • Few-shot: include at least one ABSTAIN exemplar so silence is learned, not penalized.
  • Structured output: { recommend: bool, element_id: string|null, confidence: 0..1, rationale: string } — trivially gateable; confidence < floor ⇒ suppress.
  • No PHI: the payload is the §4.2 structured context + candidate ids/counts. No patient identifiers, no clinical free-text, no values — which is why §4.2 forbids them at capture time.

10. Config-as-Data (thresholds are rule rows, not constants)

S_min, C_min, M_min, lift_min, LLM confidence_floor, per-session cap, dismissal cooldown, personalization λ, and the backoff ladder itself live as editable rows in the same JSON-predicate shape as policy_gates / cds_rules / detection_rules — scoped by tenant / role / dept_type / encounter_class. A chatty clinic can be dialed conservative without a code change; a new market tunes per region. Default seed = conservative (nudges rarely).


11. Surfacing UX

A small, non-modal NextStepNudge (chip/popover near the active tab rail, or a slot in the existing WidgetRail), shown only when all gates pass. Affordances: Accept (dispatches the existing actionModalResolver/openModal — same path real tabs use, zero new action plumbing), Dismiss (emits nudge_dismiss), auto-hide on next interaction. Never steals focus, never blocks. Behind a per-tenant feature flag + role gate.


12. Tier 2 — Form Autofill (future)

The same engine, one rung harder. This is the FORM-FILL tool class from unified-clinical-assistant.md, whose linchpin is forms_registry (web/src/services/forms/formRegistry.service.ts + schema_json): one populated FormFieldDefinition[] per form feeds both the autofill suggester and a human “what can I prefill” palette. RAG over the patient’s prior structured forms + cohort patterns proposes field values; the clinician confirms every field. Gated behind the same recommender-only + human-confirm contract, plus the stricter PHI posture (structured-only payload, regional endpoint, per-inference audit) the e-Kardex/RUDS docs already establish. Depends on the agent runner (runAgentLoop) from unified-clinical-assistant.md, which is still a design, not code — so Tier 2 is explicitly after the runner ships.


13. Safety Invariants

  1. AI recommends, never acts. A nudge proposes; the human invokes via the existing action path.
  2. AI never gates. It cannot block, hide, or reorder real workflow controls.
  3. Abstain is first-class. Silence is the default and the correct answer under weak evidence.
  4. No PHI in the stream or the prompt — structured ids/counts only, enforced at capture (§4.2).
  5. Full audit — every show/accept/dismiss logged (navigation_ai_suggestions_log).
  6. Per-tenant flag + role gate + kill switch.
  7. Fail-open — capture and recommendation are best-effort; their failure never degrades the clinical UI.
  8. Cold-start = silence — empty candidates produce no nudge, by construction (§6/§7).

14. Rollout

Phase Deliverable LLM? Demoable
P0 ui_interaction_events table + useInteractionTelemetry emitter wired at the §4.3 mount points; data starts accumulating no data only
P1 navigation_patterns MV + get_navigation_suggestions RPC (backoff) + NextStepNudge + Gates 0/1/3 + threshold rule rows no — pure SQL safe, cheap, demoable
P2 Ollama re-rank (Gate 2, abstain-first prompt) + navigation_ai_suggestions_log RLHF yes
P3 Personalization λ tuning + per-market threshold seeds + observability (nudge accept/dismiss rates per context) yes
P4 Tier-2 form autofill (after runAgentLoop ships) yes future

Start at P0→P1. P1 is the whole user-visible feature with zero LLM — frequency + backoff + gates. Add the model in P2 only for tie-breaking and phrasing.


15. Open Decisions

  1. Retention/downsampling for ui_interaction_events — raw events are high-volume. Keep raw N days, then roll into the MV and drop? (recommend: 30d raw, MV is the durable artifact.)
  2. ngram depth k — start k≤2; measure whether k=3 improves accuracy enough to justify the extra sparsity.
  3. Unified vs per-domain audit log — fold into vision_ai_suggestions_log (Open Decision #2 of the validation doc) vs standalone.
  4. Backoff ladder per dept_type — should ER/IPD/OPD have different ladders (e.g. ER weights acuity higher)? (recommend: default ladder + per-dept_type override rows.)
  5. Surface placementWidgetRail slot vs inline chip on the tab rail. (recommend: prototype both in sandbox.)

  • docs/architecture/ai-recommendation-engine-validation.md — the 5-domain engine this extends (3-tier signal, MV→RPC→Ollama template, the “don’t design new architecture” rule)
  • docs/architecture/medication-ai-planner.md — the reference implementation to template from
  • docs/architecture/ai-training-corpus.mdthe separate de-identified training/eval data dump fed from the §4 capture streams (medallion ML-corpus, versioned immutable snapshots, RAG / fine-tune / seq-model outputs)
  • docs/architecture/rogue-user-detection-system.mduser_action_events, two-tier scorer, recommender-only AI stance this mirrors
  • docs/architecture/unified-clinical-assistant.md — the FORM-FILL tool class + forms_registry linchpin for Tier 2
  • docs/architecture/encounter-orchestrator-triggers.mdencounter_journey_cache (source of the context dimensions)
  • docs/architecture/widget-rail-surface-system.md — candidate surface for the nudge
Ask Anything