Pain-Scale Picker — Implementation Spec¶
Status: Draft — ready for parallel BE + FE implementer dispatch Author: planning agent, 2026-06-11 Issue: TBD (open from this spec) Owner: SD (CPO/CTO) → 2 implementer agents (BE Sonnet, FE Sonnet) Flag:
pain_scale_picker_enabled(Flagsmith, default OFF)
1. Overview¶
A visual 0–10 pain-rating picker that replaces the LLM's plain-text "On a scale from 0 to 10..." question in patient intake conversations. The picker is emitted as a rich_content card on hip/knee/spine SOPs whose must_collect includes pain_score. Patients tap a number; the FE submits it as structured extra_metadata.pain_score via the existing chat endpoint; the BE writes it into layer_state.medical_status.data.pain_score so the next turn does not re-ask.
Blast radius: one new rich_content type (pain_scale_picker), one new FE component, one new SOP field (picker_type) on must_collect entries (additive — older SOPs ignore it), one prompt-rule addition to conversation_v6_2.yaml, one warning rule in response_policy.py. No DB schema change. Single Flagsmith flag toggles emission.
2. User experience¶
2.1 Mobile (375 px, primary)¶
┌──────────────────────────────────────────┐
│ Curaway avatar │
│ │
│ Where would you place the pain today? │ ← LLM-authored text
│ │
│ ┌────────────────────────────────────┐ │
│ │ No pain Worst imaginable │ │ ← optional anchors
│ │ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐│ │
│ │ │0 │1 │2 │3 │4 │5 │6 │7 │8 │9 │10││ │ ← 11 buttons, 28px wide, 44px tall
│ │ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘│ │ teal #008B8B border, white fill
│ │ │ │
│ │ [ Submit (disabled) ] │ │ ← coral #FF7F50 when enabled, 48px
│ └────────────────────────────────────┘ │
│ │
│ 10:42 AM │
└──────────────────────────────────────────┘
Touch state — tapped 7:
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │0 │1 │2 │3 │4 │5 │6 │■7│8 │9 │10│ │ ← selected = teal fill, white digit,
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ │ 1.05× scale, haptic ping
│ [ Submit → ] │ ← coral fill, white text
After submit: the picker collapses into a static user-bubble-style chip:
Then the next LLM turn arrives, having received the value, and asks the nextmust_collect item without re-asking pain.
2.2 Desktop (≥768 px)¶
Identical layout, max-width 480 px (matches FileRequestCard md:max-w-[480px] at apps/patient-app/src/components/chat/cards/FileRequestCard.tsx:29). Buttons grow to 36 px wide. Hover state adds 1px teal ring.
2.3 States¶
idle— no value selected; submit disabled (50% opacity,aria-disabled=true).tapped(value)— single value selected; submit enabled.submitting— submit pressed; buttons + submit disabled; spinner on submit.submitted— collapsed chip rendered; card is read-only.
State persists in component-local React state. The chip rendering after submit is driven by the parent — once the user message lands and the next assistant turn streams, this card is in chat history and its DOM is no longer mounted on subsequent renders (mirrors PortRecordsConsentCard collapse pattern).
2.4 Accessibility¶
- Each numeric button:
role="radio",aria-label="Pain level {n}",aria-checked={selected}. - Wrapper:
role="radiogroup"witharia-labelledbypointing to the question text. - Keyboard: arrow keys move focus between buttons; Space/Enter selects; Tab moves to Submit. Mirrors WAI-ARIA radio group pattern.
- Submit button:
aria-label="Submit pain rating". prefers-reduced-motionrespected — disable scale + haptic ping (per FE DoD checklist).- WCAG 2.1 AA contrast: teal
#008B8Bon white ≥ 4.5:1. White on teal selected ≥ 4.5:1. Verified.
3. BE rich_content payload shape¶
3.1 Discriminator + shape¶
Mirroring the established mso_scheduling / file_request / facilitator_consent_request convention (app/agents/orchestrator_phases/intake_triage.py:389, app/agents/orchestrator_phases/consent.py:89-96), the picker is emitted via the message envelope as:
# app/agents/orchestrator_phases/intake_triage.py — additive emission
rich_content["pain_scale_picker"] = {
"type": "pain_scale_picker",
"field_id": "pain_score", # MUST match intake_fields.yaml id
"question": "Where would you place the pain today?", # LLM-authored, ≤80 chars
"scale_min": 0,
"scale_max": 10,
"verbal_anchors": { # optional
"min": "No pain",
"max": "Worst imaginable",
},
"submit_label": "Submit",
}
return {
"response": turn.get("response_text", ""),
"content_type": "pain_scale_picker",
"rich_content": rich_content,
"agent_used": "triage_agent",
...
}
3.2 Persistence¶
Persisted via existing path — Message.content_type = "pain_scale_picker" and Message.rich_content JSONB (app/models/message.py:50,55). Update required: add "pain_scale_picker" to VALID_CONTENT_TYPES (app/models/message.py:16-26).
3.3 Pydantic schema (typed contract)¶
New file: app/schemas/pain_scale_picker.py
from typing import Literal
from pydantic import BaseModel, Field
class VerbalAnchors(BaseModel):
min: str = Field(..., max_length=24)
max: str = Field(..., max_length=24)
class PainScalePickerCard(BaseModel):
type: Literal["pain_scale_picker"] = "pain_scale_picker"
field_id: Literal["pain_score"] # extensible later; only one for now
question: str = Field(..., max_length=80)
scale_min: Literal[0] = 0
scale_max: Literal[10] = 10
verbal_anchors: VerbalAnchors | None = None
submit_label: str = "Submit"
class PainScaleSubmission(BaseModel):
field_id: Literal["pain_score"]
value: int = Field(..., ge=0, le=10)
submitted_at_ms: int # client-side epoch ms; for latency telemetry
3.4 Submission contract (patient → BE)¶
Patient tap → FE calls api.sendMessage(caseId, "<value>/10", undefined, { pain_score: { field_id: "pain_score", value: 7, submitted_at_ms: 1717999999000 }}).
The extra_metadata.pain_score key mirrors the existing extra_metadata.port_consent pattern (app/agents/case_orchestrator.py:291). case_orchestrator.run_case_orchestrator receives extra_metadata (signature already at app/agents/case_orchestrator.py:76). A new short-circuit handler runs BEFORE LLM dispatch:
# app/agents/case_orchestrator.py — after line 309, before LLM dispatch
pain_submission = (extra_metadata or {}).get("pain_score")
if pain_submission is not None:
from app.agents.orchestrator_phases.intake_triage import handle_pain_scale_submit
submit_result = await handle_pain_scale_submit(
db, case, pain_submission, langfuse_handler,
)
if submit_result is not None:
return submit_result
handle_pain_scale_submit validates with PainScaleSubmission, writes the value to case.layer_state["medical_status"]["data"]["pain_score"] (canonical location per config/intake_fields.yaml:91-93), bumps layer completion, then falls through to a normal LLM turn whose patient_context now includes the score — so the LLM continues onto the next must_collect item.
3.5 Message text the patient sees¶
The user-side message bubble shows "Pain: 7 / 10" (not raw JSON). The actual Message.content written to the conversation table is the literal "7" (clean for re-extraction); Message.extra_metadata carries {"input_method": "pain_scale_picker"} so the renderer can display the formatted chip.
4. FE component design¶
4.1 New file: apps/patient-app/src/components/chat/cards/PainScalePickerCard.tsx¶
interface PainScalePickerCardProps {
rc: Record<string, unknown>; // matches RichCard prop convention
caseId?: string;
api?: ReturnType<typeof useCaseApi>;
isReadOnly?: boolean; // true when re-rendering historic messages
}
Mirrors FileRequestCard shell (apps/patient-app/src/components/chat/cards/FileRequestCard.tsx:29) for visual consistency:
<div className="bg-chat-surface rounded-xl border-t-[3px] border-t-chat-brand
border border-chat-border shadow-sm p-4 max-w-full md:max-w-[480px]">
4.2 State¶
type PickerState =
| { kind: 'idle' }
| { kind: 'tapped'; value: number }
| { kind: 'submitting'; value: number }
| { kind: 'submitted'; value: number };
Transitions: idle → tapped (on click) → tapped (on re-click different value) → submitting (on Submit) → submitted (on api resolved). On error: submitting → tapped with toast.
4.3 Submission¶
await api.sendMessage(
caseId,
`${value}`, // user-visible message content
undefined,
{
pain_score: {
field_id: 'pain_score',
value,
submitted_at_ms: Date.now(),
},
},
);
4.4 RichCard dispatcher entry¶
Add to apps/patient-app/src/components/chat/RichCard.tsx between file_request and port_records_consent (line ~125):
if (contentType === 'pain_scale_picker') {
const card = (rc.pain_scale_picker as Record<string, unknown>) ?? rc;
return (
<PainScalePickerCard
rc={card}
caseId={caseId}
api={api}
/>
);
}
(Pattern mirrors mso_scheduling extraction at RichCard.tsx:205.)
4.5 Brand tokens¶
| Token | Use |
|---|---|
text-chat-brand (= teal #008B8B) |
button border, border-top accent |
bg-chat-brand (= teal) |
selected button fill |
bg-coral-500 (= #FF7F50) |
Submit CTA fill |
text-deep-ocean (= #004D4D) |
question heading |
font-sans (= Montserrat) |
all text |
| 8 px grid | p-4, gap-2, mb-3 |
4.6 Touch targets¶
Each number button: min-w-[28px] min-h-[44px] px-2 on mobile; full-row hit area ≥ 44 px. Submit: h-12 w-full (48 px). Per DoD .claude/rules/definition-of-done.md Frontend section.
4.7 Right-to-left¶
Wrap with dir="auto" (matches MessageBubble.tsx:97); button order remains 0→10 LTR (numeric scale is LTR by international convention even in RTL UIs).
5. SOP annotation contract¶
5.1 Schema additive change¶
Extend must_collect from plain string list to also accept structured entries with a picker_type. Mirrors existing _schema.md (config/prompts/sops/_schema.md:34-39) by extending — not replacing — the current grammar:
layers:
- id: mobility_conditioning
priority: 1
must_collect:
- primary_complaint # legacy string form (unchanged)
- affected_side
- symptom_duration_months
- id: pain_score # new structured form
picker_type: scale_0_10
verbal_anchors:
min: "No pain"
max: "Worst imaginable"
- walking_distance
- stairs_ability
The SOP loader (app/services/sop_loader.py) must accept both shapes. Backwards compatibility: a plain string pain_score continues to work and emits no picker; only the structured form opts in. Existing SOP drift CI (tests/test_sop_intake_field_drift.py) is updated to extract the id from structured entries.
5.2 Worked example — config/prompts/sops/thr.yaml¶
Modify thr.yaml:38-45 (mobility_conditioning layer):
must_collect:
- primary_complaint
- affected_side
- symptom_duration_months
- id: pain_score
picker_type: scale_0_10
verbal_anchors:
min: "No pain"
max: "Worst imaginable"
- walking_distance
- stairs_ability
Also update all 16 other SOPs that include pain_score in must_collect (full list, SD decision 2026-06-11):
bilateral_knee_replacement.yamlcervical_decompression.yamlfracture_fixation.yamlknee_arthroscopy.yamllaminectomy.yamllumbar_decompression.yamlmeniscus_repair.yamlpost_surgical_rehab.yamlrobotic_hip_replacement.yamlrobotic_knee_replacement.yamlrotator_cuff_repair.yamlscoliosis_correction.yamlshoulder_arthroscopy.yamlshoulder_replacement.yamlspinal_fusion.yamltkr.yaml
acl_repair.yaml and _generic.yaml are intentionally skipped — they do not have pain_score in must_collect.
5.3 Supported picker_type values¶
Phase 1: only scale_0_10. Reserved (do not implement yet): enum_radio, yes_no, multi_select.
6. LLM emission rule (conversation_v6_2.yaml)¶
6.1 Rule wording to add¶
Insert into config/prompts/base/conversation_v6_2.yaml under a new ## PICKER EMISSION heading (after the existing rich_content guidance around line 418):
## PICKER EMISSION
When the active SOP layer's `must_collect` entry for the current field
declares `picker_type: scale_0_10`, the patient-facing `message` field
MUST NOT contain the text question for that field. The picker IS the
question. Set `response_text` to a single short conversational lead-in
(≤ 80 chars) ending with a colon or natural pause — the picker renders
below it. Do NOT include "on a scale of 0 to 10", "rate your pain",
"0 being no pain", or similar phrasings in the same turn — that is
the broken-record anti-pattern.
GOOD:
message: "Where would you place the pain today?"
(picker renders below; structured field_id=pain_score)
BAD (broken record):
message: "On a scale from 0 to 10, where would you place the pain today,
with 0 being no pain and 10 being the worst imaginable?"
(LLM re-states the scale that the picker already shows)
BAD (silent picker):
message: ""
(no lead-in — patient sees a naked widget)
When the patient has already submitted a value (visible in the prior
user turn as a bare integer 0-10 with input_method=pain_scale_picker),
do NOT re-ask. Move to the next must_collect item.
6.2 Where the picker actually gets attached¶
The LLM does NOT emit the picker JSON itself. Instead, a deterministic post-process step in app/agents/orchestrator_phases/intake_triage.py (after the LLM turn, before the response returns) inspects the active SOP layer + which must_collect field the LLM is asking about and mechanically attaches the picker card if conditions match. This mirrors the _maybe_emit_mso_offer pattern (intake_triage.py:376-398).
Detection signal: the LLM's suggested_next field (from the v4/v6_2 JSON envelope, see app/agents/conversation_v4_parser.py:108) is the canonical pointer. Fallback: scan the LLM's message for the field's example_questions triggers, or fall back to "next unsatisfied must_collect with picker_type". Phase 1 uses suggested_next == "pain_score" as the primary trigger.
7. Validator hook¶
7.1 New warning rule in config/voice_rules.yaml¶
Add to forbidden_phrases:
- phrase: "on a scale from 0 to 10"
severity: warning
tier: brand_tone
reason: |
Broken-record anti-pattern. When pain_scale_picker_enabled fires for
this turn, the picker IS the question. Repeating the scale verbally
duplicates the widget. Warning-only — does not block emission.
applies_when: "rich_content.pain_scale_picker is set"
- phrase: "rate your pain from 0 to 10"
severity: warning
tier: brand_tone
reason: "Same as above — broken-record anti-pattern with picker."
applies_when: "rich_content.pain_scale_picker is set"
7.2 Validator code¶
Extend app/services/response_policy.py:check_response to accept an optional context: dict | None kwarg carrying rich_content. When a rule has applies_when: "rich_content.pain_scale_picker is set", the rule only fires if context.get("rich_content", {}).get("pain_scale_picker") is not None.
Output: violation appended at tier="brand_tone" (logged + Metabase dashboard), severity="warning" — does NOT cause passes=False (per existing convention at response_policy.py:242-244). Surfaces in voice audit reports.
7.3 Where it's called¶
app/agents/orchestrator_phases/intake_triage.py after picker attachment, before return:
from app.services.response_policy import check_response
_passes, _violations = check_response(
turn.get("response_text", ""),
context={"rich_content": rich_content},
)
if _violations:
logger.warning("Voice violation in picker turn: %s", [v.phrase for v in _violations])
8. Submission flow (end-to-end)¶
Turn N (LLM emits picker)
┌────────────────────────────────────────────────────────────────┐
│ 1. Patient WS receives stream_end with │
│ content_type="pain_scale_picker" │
│ rich_content.pain_scale_picker={...} │
│ │
│ 2. MessageBubble routes to RichCard │
│ (MessageBubble.tsx:51 check holds) │
│ │
│ 3. RichCard dispatches to PainScalePickerCard │
│ │
│ 4. Patient taps value 7 │
│ -> PickerState: tapped(7), Submit enabled │
│ │
│ 5. Patient taps Submit │
│ -> api.sendMessage(caseId, "7", undefined, { │
│ pain_score: { │
│ field_id: "pain_score", │
│ value: 7, │
│ submitted_at_ms: <epoch> │
│ } │
│ }) │
│ │
│ 6. POST /api/v1/cases/{caseId}/chat │
│ body = { message: "7", extra_metadata: { pain_score: {...}}}│
└────────────────────────────────────────────────────────────────┘
Turn N+1 (BE handles structured submit)
┌────────────────────────────────────────────────────────────────┐
│ 7. chat.py persists user message ("7") with │
│ extra_metadata.input_method="pain_scale_picker" │
│ (mirror of attachment_meta at chat.py:180) │
│ │
│ 8. run_case_orchestrator(extra_metadata={"pain_score": {...}}) │
│ │
│ 9. NEW handler handle_pain_scale_submit runs BEFORE LLM: │
│ - validates PainScaleSubmission │
│ - writes case.layer_state │
│ .medical_status.data.pain_score = 7 │
│ - emits PostHog event picker.submitted (no PHI) │
│ - falls through to normal LLM dispatch │
│ │
│ 10. LLM runs with updated patient_context including pain_score │
│ -> voice rule: "patient already answered pain — don't re-ask"│
│ -> LLM asks the next must_collect item (walking_distance) │
└────────────────────────────────────────────────────────────────┘
9. Telemetry¶
9.1 Events¶
| Event | When | Properties |
|---|---|---|
pain_picker.emitted |
BE emits the card (intake_triage.py) | case_id (UUID), sop_id, layer_id, tenant_id |
pain_picker.submitted |
BE receives submission | case_id, value (0-10), latency_ms (submitted_at_ms - emitted_at_ms), tenant_id |
pain_picker.abandoned |
Next assistant turn arrives with no submission | case_id, turns_since_emit, tenant_id |
9.2 PHI discipline (per CLAUDE.md §8a)¶
value(0-10) is a clinical observation — it CAN go in telemetry (it is the structured data point, not free text).- NEVER include the patient's free-text message alongside the value in any Sentry/PostHog event. The submission flow guarantees
Message.content = "7"(clean integer), but the next user message after this one may be free text — those events must scrub. tenant_id,case_id(UUID),sop_idare safe IDs.- No
patient_id, no patient name. (Aligns withfeedback PostHog trackingpatterns inapps/patient-app/src/lib/posthog.ts.) - Sentry
before_sendalready scrubs PHI; addingpain_picker.*tags requires verifying the keys are in_SAFE_EXTRA_KEYS(app/observability/sentry.py).
9.3 Dashboard¶
Add Metabase panel: pain_picker_funnel (emitted → submitted → abandoned) grouped by tenant.
10. Test plan¶
10.1 BE unit tests¶
tests/unit/agents/test_pain_scale_picker_emission.py:
- Given THR case + active layer = mobility_conditioning + suggested_next == "pain_score" → rich_content.pain_scale_picker is non-null + content_type == "pain_scale_picker"
- Given same case but pain_scale_picker_enabled flag OFF → no picker emitted (LLM text path holds)
- Given SOP without picker_type annotation → no picker emitted
tests/unit/agents/test_pain_scale_picker_submission.py:
- Valid submission {field_id: pain_score, value: 7, submitted_at_ms: ...} → case.layer_state.medical_status.data.pain_score == 7
- Out-of-range (value: 11) → 422 from pydantic
- Wrong field_id → handler returns None (falls through)
- Submission while no picker outstanding → handler returns None (idempotent / no-op)
tests/unit/services/test_sop_loader_picker_type.py:
- SOP with structured must_collect entry loads correctly
- SOP drift CI (test_sop_intake_field_drift.py) extracts id from structured entries
tests/unit/services/test_response_policy_broken_record.py:
- "On a scale from 0 to 10..." text + rich_content.pain_scale_picker set → warning emitted
- Same text without picker context → no violation (rule does not fire)
10.2 FE unit tests¶
apps/patient-app/src/components/chat/cards/PainScalePickerCard.test.tsx:
- Renders 11 buttons (0–10)
- Submit disabled until tap
- Tap 7 → button styled selected, submit enabled
- Tap different number → previous deselected
- Submit click → api.sendMessage called with correct payload shape
- isReadOnly → no buttons clickable, value shown as static chip
- Verbal anchors rendered when provided; omitted gracefully when absent
apps/patient-app/src/components/chat/RichCard.test.tsx (extend):
- pain_scale_picker rich_content dispatches to PainScalePickerCard
10.3 FE Playwright¶
apps/patient-app/e2e/pain-scale-picker.spec.ts:
- Full intake turn on hip mock case:
1. Send opening message "I need hip replacement"
2. Reach mobility_conditioning layer
3. Assert picker rendered (role=radiogroup)
4. Tap 7 → tap Submit
5. Assert next assistant turn arrives
6. Assert next turn does NOT contain "scale of 0 to 10"
7. Assert next turn asks about walking_distance OR stairs_ability
- Accessibility flow:
1. Picker rendered
2. Tab into picker → arrow keys move focus
3. Space selects value
4. Tab to submit → Enter submits
5. Assert axe-core: 0 violations
- Mobile viewport (375 × 667): all targets ≥ 44 px (verify via Playwright bounding boxes)
10.4 Coverage gates (per DoD)¶
- BE unit: ≥ 90% of new code lines
- FE unit + Playwright: ≥ 80% of new component
- A11y: 0 axe-core violations on the picker
11. Rollout¶
11.1 Flagsmith flag¶
- Name:
pain_scale_picker_enabled - Default:
false(per DoD Feature Flags section) - File: add to
config/feature_flags.yamlanddocs/reference/feature-flags.md - Tenant override: SD can flip
tenant-apollo-001(Sindhu identity) totruefor canary - Percentage rollout: start at 10% of cases on
tenant-apollo-001once design preview signed off, then 50%, then 100%, then promote to default
11.2 Rollback¶
Flip flag to false. The BE emission code path becomes inert (no picker in rich_content); the LLM falls back to its existing text question (current prod behavior). No data migration; no FE redeploy required — the FE component simply won't be invoked.
11.3 Design preview prerequisite (per feedback_show_ux_mocks_before_wiring)¶
Hard gate on FE PR merge: the FE PR must include a new design-system fixture + preview page:
- apps/design-system/src/fixtures/painScalePickerCard.ts (idle, tapped, submitted, no-anchors variants)
- apps/design-system/src/pages/PainScalePickerPreview.tsx
- Linked from DesignSystem.tsx index
- Vercel preview URL posted in the PR description for SD review
SD must sign off on the preview before FE merge. No design-system fixture = no FE PR merge.
12. Risks + open questions¶
Risks¶
- LLM does not reliably emit
suggested_next: "pain_score"— fallback heuristic (scan message for example_question triggers) may misfire. Mitigation: log emission decisions; bias to "no picker" rather than spurious picker. - Picker emitted but patient types a number anyway — they ignore the widget. Need to handle "user typed 7" as if they submitted the picker (text path already does this via LLM re-extraction). No regression.
- Picker emitted on the wrong turn — e.g. patient just shared a grave disclosure and we slap a numeric widget on top. Mitigation: do not emit when
pfs_bandindicates high distress or whenverbal_acknowledgement_rulesmatched on the prior turn. (Seethr.yaml:955.) - Streaming + picker collision — token streaming + late-attached rich_content. Existing
stream_endevent already carriesrich_content(useWebSocket.test.ts:465). No new infra. - i18n — question text is LLM-authored so locale-aware. Verbal anchors are SOP YAML — Phase 1 English only; later locales need parallel YAML.
SD decisions (resolved 2026-06-11)¶
- Touch target: hybrid slider + buttons. Draggable slider thumb on the continuous 0-10 track for coarse selection; row of 11 micro-buttons (0-10) UNDERNEATH the slider for precise fine-tune. Both inputs are bidirectionally synced — moving the slider updates the selected button highlight; tapping a button snaps the slider thumb. Either input alone is sufficient to submit. State machine + a11y unchanged.
- Verbal anchors: "No pain" / "Worst imaginable" — standard Numeric Rating Scale (NRS) clinical convention.
- SOP scope: ALL 17 SOPs that touch
pain_score— bilateral_knee_replacement, cervical_decompression, fracture_fixation, knee_arthroscopy, laminectomy, lumbar_decompression, meniscus_repair, post_surgical_rehab, robotic_hip_replacement, robotic_knee_replacement, rotator_cuff_repair, scoliosis_correction, shoulder_arthroscopy, shoulder_replacement, spinal_fusion, thr, tkr. (acl_repair and _generic do not have pain_score in must_collect, skipped.) - Re-emit on contradiction: NO. If patient submits a value then later free-text contradicts (e.g. "pain is unbearable" after submitting 3), trust the LLM to re-extract verbally and update the structured value. Picker is one-shot per layer; second emission would feel like the agent doesn't trust the patient's input.
- Canary tenant:
tenant-apollo-001(Sindhu identity). Same canary surface used for v6.2 rollout. SD's hip case fixtures live here. Consistent observability with prior rollouts.
UX update for hybrid slider + buttons (overrides §2.1)¶
The picker now has BOTH inputs in a single card, stacked vertically:
┌──────────────────────────────────────────┐
│ Where would you place the pain today? │
│ │
│ No pain Worst imaginable │ ← anchors top
│ ●═══════════════════○═══════════ │ ← slider, thumb at 7
│ │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │0 │1 │2 │3 │4 │5 │6 │■7│8 │9 │10│ │ ← buttons mirror slider value
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ │
│ │
│ [ Submit → ] │
└──────────────────────────────────────────┘
Slider implementation: native <input type="range" min="0" max="10" step="1"> with custom thumb + track styling via Tailwind / brand tokens. Buttons mirror the slider's value via a single source-of-truth value state in React.
A11y: slider gets aria-label="Pain level slider", native keyboard support (arrow keys move thumb). Buttons remain role="radiogroup" with their existing a11y contract. Either input updates the same selected value. Tabbing order: anchors → slider → buttons (0-10) → submit.
13. Files to create / modify¶
Backend (curaway-ai/curaway-backend)¶
| File | Action | LoC estimate |
|---|---|---|
app/schemas/pain_scale_picker.py |
new — Pydantic schemas | ~40 |
app/models/message.py |
modify — add "pain_scale_picker" to VALID_CONTENT_TYPES |
+1 |
app/agents/orchestrator_phases/intake_triage.py |
modify — emit picker post-LLM; add handle_pain_scale_submit |
~80 |
app/agents/case_orchestrator.py |
modify — short-circuit handler for extra_metadata.pain_score (mirror port_consent at line 291) |
~15 |
app/services/sop_loader.py |
modify — accept structured must_collect entries |
~30 |
app/services/response_policy.py |
modify — context kwarg + applies_when filter |
~25 |
app/services/feature_flags.py |
modify — register pain_scale_picker_enabled |
+2 |
config/voice_rules.yaml |
modify — 2 new warning rules | +18 |
config/feature_flags.yaml |
modify — flag entry | +6 |
config/prompts/base/conversation_v6_2.yaml |
modify — ## PICKER EMISSION section | +40 |
config/prompts/sops/_schema.md |
modify — document structured must_collect entry |
+15 |
| 17 SOP yaml files (see §5.2 expanded list below) | modify — annotate pain_score entry |
~85 (5 LoC × 17) |
tests/unit/agents/test_pain_scale_picker_emission.py |
new | ~120 |
tests/unit/agents/test_pain_scale_picker_submission.py |
new | ~100 |
tests/unit/services/test_sop_loader_picker_type.py |
new | ~60 |
tests/unit/services/test_response_policy_broken_record.py |
new | ~40 |
tests/test_sop_intake_field_drift.py |
modify — handle structured must_collect entries |
+15 |
docs/reference/feature-flags.md |
modify — flag doc | +5 |
BE total: ~640 LoC across 21 files. Splits into 2 sub-PRs if > 500 LoC: (a) schema + sop_loader + voice_rules + thr.yaml + flag + unit tests; (b) orchestrator wiring + Playwright support.
Frontend (curaway-ai/curaway-frontend)¶
| File | Action | LoC estimate |
|---|---|---|
apps/patient-app/src/components/chat/cards/PainScalePickerCard.tsx |
new | ~140 |
apps/patient-app/src/components/chat/cards/PainScalePickerCard.test.tsx |
new | ~120 |
apps/patient-app/src/components/chat/RichCard.tsx |
modify — dispatcher entry | +12 |
apps/patient-app/src/components/chat/RichCard.test.tsx |
modify — add dispatch test | +10 |
apps/patient-app/src/components/chat/MessageBubble.tsx |
(no change — line 51 check already routes by content_type + rich_content) | 0 |
apps/patient-app/src/types/api.ts |
modify — add PainScalePickerPayload type to discriminated union |
+20 |
apps/design-system/src/fixtures/painScalePickerCard.ts |
new — fixture states | ~80 |
apps/design-system/src/pages/PainScalePickerPreview.tsx |
new — preview page | ~120 |
apps/design-system/src/pages/DesignSystem.tsx |
modify — link to preview | +5 |
apps/patient-app/e2e/pain-scale-picker.spec.ts |
new — Playwright | ~150 |
FE total: ~660 LoC across 10 files. Single PR.
14. Implementer briefing¶
14.1 BE implementer brief (≤ 300 words)¶
You are implementing a structured-data picker mechanism for patient intake. The flag is pain_scale_picker_enabled (default OFF).
What to build:
- Schema —
app/schemas/pain_scale_picker.pywithPainScalePickerCard(emission payload) andPainScaleSubmission(incoming patient submission). Use the shapes in spec §3. - SOP loader — extend
app/services/sop_loader.pyto acceptmust_collectentries as either plain strings (legacy) or dicts with{id, picker_type, verbal_anchors}(new). Drift CI (tests/test_sop_intake_field_drift.py) must continue to pass — it extracts field IDs from both shapes. - Emission hook — in
app/agents/orchestrator_phases/intake_triage.py, after the LLM turn and before return, inspect the active SOP layer +turn["suggested_next"]. If matches amust_collectentry withpicker_type: scale_0_10AND the field is unset inlayer_stateANDpain_scale_picker_enabledis True for the tenant → attachrich_content["pain_scale_picker"] = {...}and setcontent_type = "pain_scale_picker". Mirror the_maybe_emit_mso_offerpattern (intake_triage.py:376-398). - Submission handler — new
handle_pain_scale_submitin same file. Called fromcase_orchestrator.run_case_orchestratorafter the port_consent block (case_orchestrator.py:289-309pattern). Validates withPainScaleSubmission, writes tocase.layer_state["medical_status"]["data"]["pain_score"], bumps completion, returns None so the LLM dispatch proceeds with the updated state. - Voice rule — extend
app/services/response_policy.check_responsewithcontext: dict | None. Addapplies_when: "rich_content.pain_scale_picker is set"interpretation. Add 2 warning rules toconfig/voice_rules.yaml(see §7). - Prompt rule — append
## PICKER EMISSIONblock inconfig/prompts/base/conversation_v6_2.yaml(exact wording in §6.1). - SOP annotations — update
thr.yaml+ 4 other ortho SOPs (§5.2 list). - Tests — 4 new pytest files (see §10.1). Coverage target 90% on new code.
Must not: introduce cross-domain imports; bypass llm_gateway; emit picker unless flag is True for the case's tenant; emit picker if pfs_band indicates high distress.
14.2 FE implementer brief (≤ 300 words)¶
You are implementing a 0–10 numeric pain picker as a new rich_content card in the patient app.
What to build:
- Component —
apps/patient-app/src/components/chat/cards/PainScalePickerCard.tsx. Card shell mirrorsFileRequestCard(FileRequestCard.tsx:29):bg-chat-surface rounded-xl border-t-[3px] border-t-chat-brand border border-chat-border shadow-sm p-4 max-w-full md:max-w-[480px]. Renders 11 buttons (0–10), optional verbal anchors above, Submit button below. State machine: idle → tapped → submitting → submitted (see §4.2). On Submit:api.sendMessage(caseId, String(value), undefined, { pain_score: { field_id: 'pain_score', value, submitted_at_ms: Date.now() }}). A11y:role="radiogroup",aria-checked, arrow-key navigation, ≥ 44 px touch targets. Respectprefers-reduced-motion. RTL:dir="auto"on text containers (mirrorMessageBubble.tsx:97). - Dispatcher — add
if (contentType === 'pain_scale_picker') { ... }branch inapps/patient-app/src/components/chat/RichCard.tsx(afterfile_request, beforeport_records_consent). Extractrc.pain_scale_picker ?? rc(mirrormso_schedulingextraction at line 205). - Types — extend
apps/patient-app/src/types/api.tswithPainScalePickerPayloadinterface. - Design-system preview (HARD gate — SD signs off before merge):
apps/design-system/src/fixtures/painScalePickerCard.ts(4 fixtures: idle, tapped, submitted, no-anchors).apps/design-system/src/pages/PainScalePickerPreview.tsxpreviewing all 4 states. Link fromDesignSystem.tsxindex. Vercel preview URL in PR description. - Tests — Vitest unit (
PainScalePickerCard.test.tsx), RichCard dispatch test, Playwright E2E (apps/patient-app/e2e/pain-scale-picker.spec.ts) covering full intake flow + a11y + 375 px viewport. See §10.2 + §10.3.
Must not: hardcode colors (use Tailwind brand tokens); skip prefers-reduced-motion; submit without a value selected; render picker for messages with isReadOnly=false outside chat history; merge without SD-approved Vercel preview URL.