Skip to content

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:

                                  ┌──────────────┐
                                  │ Pain: 7 / 10 │  ← teal background, right-aligned
                                  └──────────────┘
                                          10:43 AM
Then the next LLM turn arrives, having received the value, and asks the next must_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" with aria-labelledby pointing 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-motion respected — disable scale + haptic ping (per FE DoD checklist).
  • WCAG 2.1 AA contrast: teal #008B8B on 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.yaml
  • cervical_decompression.yaml
  • fracture_fixation.yaml
  • knee_arthroscopy.yaml
  • laminectomy.yaml
  • lumbar_decompression.yaml
  • meniscus_repair.yaml
  • post_surgical_rehab.yaml
  • robotic_hip_replacement.yaml
  • robotic_knee_replacement.yaml
  • rotator_cuff_repair.yaml
  • scoliosis_correction.yaml
  • shoulder_arthroscopy.yaml
  • shoulder_replacement.yaml
  • spinal_fusion.yaml
  • tkr.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_id are safe IDs.
  • No patient_id, no patient name. (Aligns with feedback PostHog tracking patterns in apps/patient-app/src/lib/posthog.ts.)
  • Sentry before_send already scrubs PHI; adding pain_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.yaml and docs/reference/feature-flags.md
  • Tenant override: SD can flip tenant-apollo-001 (Sindhu identity) to true for canary
  • Percentage rollout: start at 10% of cases on tenant-apollo-001 once 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

  1. 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.
  2. 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.
  3. 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_band indicates high distress or when verbal_acknowledgement_rules matched on the prior turn. (See thr.yaml:955.)
  4. Streaming + picker collision — token streaming + late-attached rich_content. Existing stream_end event already carries rich_content (useWebSocket.test.ts:465). No new infra.
  5. 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)

  1. 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.
  2. Verbal anchors: "No pain" / "Worst imaginable" — standard Numeric Rating Scale (NRS) clinical convention.
  3. 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.)
  4. 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.
  5. 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:

  1. Schemaapp/schemas/pain_scale_picker.py with PainScalePickerCard (emission payload) and PainScaleSubmission (incoming patient submission). Use the shapes in spec §3.
  2. SOP loader — extend app/services/sop_loader.py to accept must_collect entries 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.
  3. 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 a must_collect entry with picker_type: scale_0_10 AND the field is unset in layer_state AND pain_scale_picker_enabled is True for the tenant → attach rich_content["pain_scale_picker"] = {...} and set content_type = "pain_scale_picker". Mirror the _maybe_emit_mso_offer pattern (intake_triage.py:376-398).
  4. Submission handler — new handle_pain_scale_submit in same file. Called from case_orchestrator.run_case_orchestrator after the port_consent block (case_orchestrator.py:289-309 pattern). Validates with PainScaleSubmission, writes to case.layer_state["medical_status"]["data"]["pain_score"], bumps completion, returns None so the LLM dispatch proceeds with the updated state.
  5. Voice rule — extend app/services/response_policy.check_response with context: dict | None. Add applies_when: "rich_content.pain_scale_picker is set" interpretation. Add 2 warning rules to config/voice_rules.yaml (see §7).
  6. Prompt rule — append ## PICKER EMISSION block in config/prompts/base/conversation_v6_2.yaml (exact wording in §6.1).
  7. SOP annotations — update thr.yaml + 4 other ortho SOPs (§5.2 list).
  8. 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:

  1. Componentapps/patient-app/src/components/chat/cards/PainScalePickerCard.tsx. Card shell mirrors FileRequestCard (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. Respect prefers-reduced-motion. RTL: dir="auto" on text containers (mirror MessageBubble.tsx:97).
  2. Dispatcher — add if (contentType === 'pain_scale_picker') { ... } branch in apps/patient-app/src/components/chat/RichCard.tsx (after file_request, before port_records_consent). Extract rc.pain_scale_picker ?? rc (mirror mso_scheduling extraction at line 205).
  3. Types — extend apps/patient-app/src/types/api.ts with PainScalePickerPayload interface.
  4. 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.tsx previewing all 4 states. Link from DesignSystem.tsx index. Vercel preview URL in PR description.
  5. 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.