Skip to content

AI-Assisted Plan Editing Feature

Last Updated: 2026-01-31

Feature Overview

Purpose

The AI-assisted plan editing feature allows therapists to modify patient workout plans using natural language instructions. Instead of manually adding, removing, or adjusting exercises one by one, therapists can describe desired changes in plain English (e.g., "make this workout easier" or "remove all dumbbell exercises") and receive AI-generated plan modifications that they can review and accept.

Architecture Diagram

┌──────────────────────────────────────────────────────────────────────────┐
│                            FRONTEND (React)                               │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────┐    ┌─────────────────────────────────────────┐ │
│  │   EditPlanModal     │    │         AIPlanEditChat                  │ │
│  │   (Container)       │◄──►│   - Message input                       │ │
│  │                     │    │   - Quick action buttons                │ │
│  │ ┌────────────────┐  │    │   - Conversation display                │ │
│  │ │ PlanComparison │  │    │   - Streaming content preview           │ │
│  │ │ View           │  │    └─────────────────────────────────────────┘ │
│  │ └────────────────┘  │                                                │
│  │                     │    ┌─────────────────────────────────────────┐ │
│  │ ┌────────────────┐  │    │       AIStreamingPreview                │ │
│  │ │ ExerciseDetail │  │    │   - Skeleton loading cards              │ │
│  │ │ Dialog         │  │    │   - Progressive workout indicators      │ │
│  │ └────────────────┘  │    └─────────────────────────────────────────┘ │
│  └─────────────────────┘                                                │
│            │                                                             │
│            ▼                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐│
│  │                      useAIPlanEdit Hook                             ││
│  │  - State management (conversation, proposed plan, streaming)       ││
│  │  - SSE stream parsing with section events                          ││
│  │  - Plan transformation and diff calculation                        ││
│  └─────────────────────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────────────┐│
│  │                    useExerciseDetails Hook                          ││
│  │  - Batch fetch exercise details for thumbnails                     ││
│  │  - Caching to avoid refetching                                     ││
│  └─────────────────────────────────────────────────────────────────────┘│
│                                    │                                     │
└────────────────────────────────────│─────────────────────────────────────┘
                                     │ POST (SSE Stream)
┌──────────────────────────────────────────────────────────────────────────┐
│                            BACKEND (Express)                              │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────────┐│
│  │              POST /v2/ai/therapist-plan-edit                        ││
│  │                                                                     ││
│  │  1. Authenticate & verify patient access                            ││
│  │  2. Fetch exercises + patient attributes                            ││
│  │  3. Build system prompt                                             ││
│  │  4. Call OpenAI API with structured schema                          ││
│  │  5. Stream response via SSE with section extraction                 ││
│  └─────────────────────────────────────────────────────────────────────┘│
│                                    │                                     │
└────────────────────────────────────│─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│                            OpenAI API                                     │
│                                                                          │
│  Model: GPT-5-mini-2025-08-07                                            │
│  Response Format: PTTherapistPlanEditSchema (structured JSON)            │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

Backend Implementation

New Endpoint: POST /v2/ai/therapist-plan-edit

File: OktaPT-API/src/routes/ai/controllers.ts (lines 1401-1651)

Request Format

interface TherapistPlanEditRequest {
  patientId: number;           // Patient whose plan is being edited
  currentPlan: {               // Current workout structure
    day_number: number;
    focus: string;
    exercises: {
      exercise_id: number;
      exercise_name: string;
      exercise_type: "REPS_BASED" | "TIME_BASED";
      exercise_block: "WARM_UP" | "MAIN_SESSION" | "SUPPLEMENTAL_WORK" | "COOL_DOWN";
      sets: number;
      reps: number | null;
      duration: number | null;
      weight: number | null;
    }[];
  }[];
  userMessage: string;         // Therapist's natural language request
  conversationHistory?: {      // Previous messages for context
    role: "user" | "assistant";
    content: string;
  }[];
}

Response Format (SSE Stream)

The endpoint uses Server-Sent Events to stream responses in real-time with progressive section extraction:

// Status event - Progress indicator
{ type: "status", message: "Analyzing your request..." }

// Section event - Extracted content as it becomes available
{ type: "section", section: "can_make_changes", content: true }
{ type: "section", section: "change_overview", content: "Reducing intensity..." }
{ type: "section", section: "explanation", content: "I've lowered the sets..." }
{ type: "section", section: "workouts_count", content: 3 }

// Delta event - Streaming text chunks (raw JSON)
{ type: "response.output_text.delta", delta: "..." }

// Done event - Final parsed response
{
  type: "done",
  result: AITherapistPlanEditResponse
}

// Error event - Error handling
{ type: "error", error: "Error description" }

Section Extraction During Streaming

The backend extracts content from partial JSON as it streams:

const extractPartialContent = (partialJson: string) => {
  // Extract boolean flag (appears early in response)
  const canMakeChangesMatch = partialJson.match(/"can_make_changes"\s*:\s*(true|false)/);

  // Extract text fields with proper escape handling
  const overviewMatch = partialJson.match(/"change_overview"\s*:\s*"((?:[^"\\]|\\.)*)"/);
  const explanationMatch = partialJson.match(/"explanation"\s*:\s*"((?:[^"\\]|\\.)*)"/);

  // Count workout objects to show progress
  const workoutMatches = partialJson.match(/"day_number"\s*:\s*\d+/g);

  return {
    can_make_changes: canMakeChangesMatch ? canMakeChangesMatch[1] === "true" : undefined,
    change_overview: overviewMatch?.[1],
    explanation: explanationMatch?.[1],
    workouts_count: workoutMatches?.length || 0,
  };
};

Implementation Details

  1. Authentication: Requires authenticated therapist via middleware
  2. Authorization: Verifies therapist-patient relationship via doctorPatientConnection table
  3. Data Retrieval:
  4. Fetches public exercises + therapist's custom exercises
  5. Retrieves patient attributes for personalization context
  6. AI Integration:
  7. Uses GPT-5-mini-2025-08-07 model
  8. Implements heartbeat mechanism to keep connection alive
  9. Extracts and emits section events during streaming
  10. Parses streamed JSON response

OpenAI Response Schema

File: OktaPT-API/lib/openai_response_formats/pt_therapist_plan_edit_schema.ts

export const PTTherapistPlanEditSchema = () => ({
  type: "object",
  properties: {
    can_make_changes: {
      type: "boolean",
      description: "Whether the AI can fulfill the request"
    },
    change_overview: {
      type: "string",
      description: "1-2 sentence summary of changes made"
    },
    explanation: {
      type: "string",
      description: "2-4 sentence explanation with reasoning"
    },
    modified_plan: {
      type: "object",
      properties: {
        workouts: {
          type: "array",
          items: {
            type: "object",
            properties: {
              day_number: { type: "number" },        // 1-10 (maps to original plan)
              focus: { type: "string" },              // Workout theme
              exercises: {
                type: "array",
                items: {
                  type: "object",
                  properties: {
                    exercise_id: { type: "number" },
                    exercise_name: { type: "string" },
                    exercise_type: {
                      type: "string",
                      enum: ["REPS_BASED", "TIME_BASED"]
                    },
                    exercise_block: {
                      type: "string",
                      enum: ["WARM_UP", "MAIN_SESSION", "SUPPLEMENTAL_WORK", "COOL_DOWN"]
                    },
                    sets: { type: "number" },
                    reps: { type: ["number", "null"] },
                    duration: { type: ["number", "null"] },  // seconds
                    weight: { type: ["number", "null"] }     // pounds
                  }
                }
              }
            }
          }
        }
      }
    },
    follow_up_questions: {
      type: ["array", "null"],
      items: { type: "string" },
      description: "Clarifying questions if can_make_changes is false"
    }
  }
});

SSE Streaming Implementation

The backend implements Server-Sent Events with progressive section extraction:

// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");

// Send status updates
res.write(`data: ${JSON.stringify({ type: "status", message: "Analyzing..." })}\n\n`);

// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
  res.write(`:heartbeat\n\n`);
}, 15000);

// Track previously extracted content
let previousExtracted = { can_make_changes: undefined, change_overview: undefined, ... };

// Stream OpenAI response chunks with section extraction
for await (const event of stream) {
  if (event.type === "response.output_text.delta") {
    accumulatedContent += event.delta;

    // Extract and emit section updates
    const currentExtracted = extractPartialContent(accumulatedContent);

    if (currentExtracted.can_make_changes !== previousExtracted.can_make_changes) {
      res.write(`data: ${JSON.stringify({
        type: "section",
        section: "can_make_changes",
        content: currentExtracted.can_make_changes
      })}\n\n`);
    }

    if (currentExtracted.workouts_count > previousExtracted.workouts_count) {
      res.write(`data: ${JSON.stringify({
        type: "section",
        section: "workouts_count",
        content: currentExtracted.workouts_count
      })}\n\n`);
    }

    // ... similar for change_overview, explanation

    previousExtracted = currentExtracted;
  }
}

// Send final parsed response
res.write(`data: ${JSON.stringify({ type: "done", result: parsedResponse })}\n\n`);

Enhanced AI Context

The AI receives comprehensive patient information to make informed plan modifications:

Workout History Fetching

The backend fetches the last 15 completed workouts with full performance data:

prisma.workout.findMany({
  where: {
    patientId,
    isWorkoutCompleted: true,
    tenantId: req.user.tenantId,
  },
  orderBy: { timeWorkoutEnd: "desc" },
  take: 15,
  select: {
    dateWorkoutScheduled: true,
    timeWorkoutEnd: true,
    name: true,
    doctorNote: true,
    exercises: {
      select: {
        plannedSets: true,
        actualSets: true,
        plannedReps: true,
        actualReps: true,
        patientRecordedPainLevel: true,
        patientRecordedPhysicalExertion: true,
        isExerciseCompleted: true,
        doctorNotesPreExercise: true,
        doctorNotesPostExercise: true,
        patientNotes: true,
        exercise: { select: { name: true } },
      },
    },
  },
})

Helper function calculateTrends(workouts, today) computes:

Metric Description
avgPainLast7d / avgPainLast30d Average pain level over time windows
exercisesWithHighPain Exercises that caused pain > 6
completionRate7d / completionRate30d Percentage of exercises completed

Medical Context

Additional queries fetch active injuries and goals:

// Active injuries/operations (not resolved)
prisma.patientInjuryAndOperation.findMany({
  where: {
    patientId,
    tenantId: req.user.tenantId,
    endDateOfInjuryOrOperation: null,
  },
  select: {
    injuryOrOperationType: true,
    isInjury: true,
    startDateOfInjuryOrOperation: true,
  },
})

// Active goals
prisma.patientGoal.findMany({
  where: {
    patientId,
    tenantId: req.user.tenantId,
    goalStatus: "IN_PROGRESS",
  },
  select: { goal: true, goalStatus: true },
})

Exercise Tags Inclusion

Exercises now include tags for better categorization:

prisma.exercise.findMany({
  where: {
    OR: [{ isPublic: true }, { createdById: doctorId }],
    isActive: true,
    isAIExercise: false,  // Exclude AI-generated exercises
    tenantId: req.user.tenantId,
  },
  select: {
    id: true,
    name: true,
    description: true,
    tags: {
      select: {
        tag: { select: { key: true } },
      },
    },
  },
})

User Attribute Extraction

The AI can extract and save patient attributes from therapist instructions for future context.

Schema Addition

The response schema includes a user_attributes field:

user_attributes: {
  type: ["array", "null"],
  description: "Extract relevant patient attributes from therapist instructions",
  items: {
    type: "object",
    properties: {
      category: {
        type: "string",
        enum: ["PHYSICAL", "MEDICAL", "BEHAVIORAL", "GOALS", "ASSESSMENT", "PREFERENCES", "CUSTOM"],
      },
      key: { type: "string" },
      value: { type: "string" },
    },
    required: ["category", "key", "value"],
  },
}

Attribute Saving

After parsing the AI response, attributes are saved via AddUserAttributesToAiModel:

if (parsedResponse.user_attributes) {
  await AddUserAttributesToAiModel(
    patientId,
    parsedResponse.user_attributes,
    req.user.tenantId
  );
}

The function checks for duplicates before creating new attributes.

Suggested Attribute Types

Category Key Example Value When Created
MEDICAL contraindicated_movements "no overhead pressing" Therapist specifies limitation
MEDICAL cleared_movements "cleared for light resistance" Therapist clears activity
ASSESSMENT pain_threshold_adjustment "reduced intensity due to flare-up" Pain-related edit
ASSESSMENT movement_limitation "limited shoulder flexion above 90deg" From observation
BEHAVIORAL adherence_note "tends to skip weekend workouts" Pattern observed
GOALS current_phase "acute rehabilitation" Treatment stage
PREFERENCES equipment_confirmed "resistance bands, yoga mat" Equipment mentioned

Frontend Implementation

Hook: useAIPlanEdit.ts

File: OktaPT-FE/lib/hooks/useAIPlanEdit.ts

Hook Signature

export const useAIPlanEdit = (
  patientId: number,
  currentWorkouts: Map<number, EditDayWorkout>
): UseAIPlanEditReturn

Streaming Content Interface

interface StreamingContent {
  change_overview?: string;
  explanation?: string;
  can_make_changes?: boolean;
  workouts_count?: number;
}

Return Interface

interface UseAIPlanEditReturn {
  conversationHistory: ChatMessage[];           // All chat messages
  proposedPlan: Map<number, EditDayWorkout> | null;  // AI-proposed changes
  changeOverview: string;                       // Summary of changes
  explanation: string;                          // Detailed explanation
  followUpQuestions: string[] | null;           // Clarifying questions
  isStreaming: boolean;                         // Loading state
  streamingStatus: string;                      // Current status message
  streamingContent: StreamingContent;           // Progressive streaming data
  error: string | null;                         // Error state
  sendMessage: (message: string) => Promise<void>;    // Send user message
  acceptProposedChanges: () => void;            // Apply changes
  rejectProposedChanges: () => void;            // Discard changes
  clearConversation: () => void;                // Reset state
  planDiff: PlanDiffResult | null;              // Calculated differences
}

Section Event Handling

// In SSE handler:
if (data.type === "section") {
  setStreamingContent((prev) => ({
    ...prev,
    [data.section]: data.content,
  }));

  // Update status based on what section we received
  if (data.section === "can_make_changes") {
    setStreamingStatus(
      data.content ? "Preparing modifications..." : "Analyzing request..."
    );
  } else if (data.section === "workouts_count") {
    setStreamingStatus(`Building day ${data.content}...`);
  } else {
    setStreamingStatus("AI is thinking...");
  }
}

Key Functions

sendMessage(message: string) - Posts to /v2/ai/therapist-plan-edit - Handles SSE stream parsing with section events - Updates streamingContent progressively - Transforms AI response to Map<number, EditDayWorkout> format - Calculates plan diff between current and proposed

acceptProposedChanges() - Signals acceptance (parent component applies changes) - Clears proposed plan and diff state - Resets overview/explanation

rejectProposedChanges() - Clears proposed plan and diff - Preserves conversation history for refinement

clearConversation() - Aborts any ongoing request - Resets all state to initial values

Internal Helpers

// Transform AI response format to application format
const transformAIResponseToWorkouts = (
  response: AITherapistPlanEditResponse
): Map<number, EditDayWorkout>

// Calculate differences between current and proposed plans
const calculatePlanDiff = (
  current: Map<number, EditDayWorkout>,
  proposed: Map<number, EditDayWorkout>
): PlanDiffResult

Hook: useExerciseDetails.ts

File: OktaPT-FE/lib/hooks/useExerciseDetails.ts

Fetches complete exercise details for displaying thumbnails in the comparison view.

interface UseExerciseDetailsReturn {
  exerciseDetails: Map<number, Exercise>;
  isLoading: boolean;
  error: string | null;
}

export const useExerciseDetails = (
  exerciseIds: number[]
): UseExerciseDetailsReturn

Features

  • Batches requests to avoid overwhelming the API
  • Caches results to prevent refetching
  • Tracks fetching state per exercise ID
  • Returns Map for O(1) lookup

Types: aiPlanEdit.ts

File: OktaPT-FE/lib/types/aiPlanEdit.ts

// Chat message structure
export interface ChatMessage {
  id: string;
  role: "user" | "assistant" | "system";
  content: string;
  timestamp: Date;
}

// AI response exercise format
export interface AIExerciseInPlan {
  exercise_id: number;
  exercise_name: string;
  exercise_type: "REPS_BASED" | "TIME_BASED";
  exercise_block: "WARM_UP" | "MAIN_SESSION" | "SUPPLEMENTAL_WORK" | "COOL_DOWN";
  sets: number;
  reps: number | null;
  duration: number | null;
  weight: number | null;
}

// AI response workout format
export interface AIWorkoutInPlan {
  day_number: number;
  focus: string;
  exercises: AIExerciseInPlan[];
}

// Complete AI response
export interface AITherapistPlanEditResponse {
  can_make_changes: boolean;
  change_overview: string;
  explanation: string;
  modified_plan: { workouts: AIWorkoutInPlan[] };
  follow_up_questions: string[] | null;
}

// Diff tracking types
export interface ExerciseChange {
  type: "added" | "removed" | "modified" | "unchanged";
  exerciseId: number;
  exerciseName: string;
  changes?: string[];  // e.g., ["sets: 3→4", "reps: 10→12"]
}

export interface DayDiff {
  dayNumber: number;
  changes: ExerciseChange[];
  summary: string;  // e.g., "2 added, 1 removed"
}

export interface PlanDiffResult {
  dayDiffs: DayDiff[];
  totalAdded: number;
  totalRemoved: number;
  totalModified: number;
}

Component: AIPlanEditChat.tsx

File: OktaPT-FE/components/plan-creation/AIPlanEditChat.tsx

Props Interface

interface AIPlanEditChatProps {
  conversationHistory: ChatMessage[];
  isStreaming: boolean;
  streamingStatus: string;
  streamingContent?: StreamingContent;
  error: string | null;
  followUpQuestions: string[] | null;
  currentWorkouts: Map<number, EditDayWorkout>;
  onSendMessage: (message: string) => void;
  onClearConversation: () => void;
  onClose: () => void;
}

Features

  1. Chat Interface
  2. Header with title and close button
  3. Scrollable message area with auto-scroll
  4. Text input with Enter to send, Shift+Enter for newline

  5. Quick Action Buttons

  6. Dynamically generated based on current plan analysis
  7. Examples: "Make easier", "Make harder", "Add warm-up", "Remove dumbbells"
  8. Analyzes exercise names to detect equipment used

  9. Message Rendering

  10. User messages: right-aligned, blue background
  11. Assistant messages: left-aligned, white background
  12. Follow-up questions rendered as clickable suggestions

  13. Streaming Content Preview

  14. Shows change_overview text as it streams
  15. Displays explanation text
  16. Shows workout count progress with spinner
  17. Animated cursor while streaming
{isStreaming && (
  <div className="bg-white border rounded-2xl px-4 py-3">
    {streamingContent?.change_overview ? (
      <div className="space-y-2">
        <p className="text-sm font-medium text-gray-900">
          {streamingContent.change_overview}
          <span className="inline-block w-1.5 h-4 bg-brand-500 animate-pulse ml-1" />
        </p>
        {streamingContent.explanation && (
          <p className="text-xs text-gray-500">{streamingContent.explanation}</p>
        )}
        {streamingContent.workouts_count > 0 && (
          <span className="text-xs text-brand-600">
            Building {streamingContent.workouts_count} workout days...
          </span>
        )}
      </div>
    ) : (
      <div className="flex items-center gap-2">
        <span className="w-2 h-2 bg-brand-500 rounded-full animate-bounce" />
        <span className="w-2 h-2 bg-brand-500 rounded-full animate-bounce" />
        <span className="w-2 h-2 bg-brand-500 rounded-full animate-bounce" />
        <span className="text-xs text-gray-500">{streamingStatus}</span>
      </div>
    )}
  </div>
)}

Component: AIStreamingPreview.tsx

File: OktaPT-FE/components/plan-creation/AIStreamingPreview.tsx

Full-page loading state shown while AI is processing, replacing the main content area.

Props Interface

interface AIStreamingPreviewProps {
  streamingContent: StreamingContent;
  streamingStatus: string;
  currentPlanDaysCount: number;
}

Features

  1. Header with Streaming Indicator
  2. Sparkles icon with animated ping
  3. Shows change_overview and explanation as they stream
  4. Progress indicators with bouncing dots

  5. Skeleton Workout Cards

  6. Shows placeholder cards for expected days
  7. Cards "light up" as workouts are detected (via workouts_count)
  8. Exercise row skeletons with animated pulse

  9. Progressive Loading

  10. Cards transition from gray (loading) to brand colors (ready)
  11. Shows "X days processed" badge
{Array.from({ length: currentPlanDaysCount }).map((_, index) => (
  <DayCardSkeleton
    key={index}
    dayNumber={index + 1}
    isLoaded={
      streamingContent.workouts_count !== undefined &&
      index + 1 <= streamingContent.workouts_count
    }
  />
))}

Component: PlanComparisonView.tsx

File: OktaPT-FE/components/plan-creation/PlanComparisonView.tsx

Displays a before/after comparison of the current and proposed workout plans.

Props Interface

interface PlanComparisonViewProps {
  currentPlan: Map<number, EditDayWorkout>;
  proposedPlan: Map<number, EditDayWorkout>;
  changeOverview: string;
  explanation: string;
  onAccept: () => void;
  onReject: () => void;
  getDateForDay: (dayNumber: number) => string;
  onExerciseClick?: (exerciseId: number, exerciseName: string) => void;
}

Visual Design

┌─────────────────────────────────────────────────────────────┐
│ AI made the following changes:                               │
│ "Reduced intensity by lowering sets..."                      │
│                                            [Reject] [Accept] │
├─────────────────────────────────────────────────────────────┤
│ Day 1 - Mon, Jan 6 - Upper Body                              │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ BEFORE                                                   │ │
│ │ [thumb] Bench Press      3x10 @ 135lbs                  │ │
│ │ [thumb] Tricep Dips      3x12                            │ │
│ │ [thumb] Lateral Raises   3x12 @ 20lbs                   │ │
│ └─────────────────────────────────────────────────────────┘ │
│                         ↓                                    │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ AFTER                                                    │ │
│ │ [thumb] Bench Press      4x10 @ 135lbs       [CHANGED]  │ │
│ │ [thumb] Push-ups         3x15                [NEW]      │ │
│ │ [thumb] Lateral Raises   3x12 @ 20lbs                   │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Features

  1. Exercise Thumbnails
  2. Uses useExerciseDetails hook to fetch complete exercise data
  3. Displays video thumbnails with play icon overlay
  4. Fallback to placeholder for exercises without media

  5. Change Indicators

  6. NEW badge: green, for added exercises
  7. CHANGED badge: amber, for modified exercises
  8. Removed exercises shown in "before" but not "after"

  9. Clickable Exercises

  10. Click to open ExerciseDetailDialog
  11. Shows video, description, and tags

Modified Component: EditPlanModal.tsx

File: OktaPT-FE/components/plan-creation/EditPlanModal.tsx

Integration Points

// Initialize AI planning hook
const {
  conversationHistory,
  proposedPlan,
  changeOverview,
  explanation,
  followUpQuestions,
  isStreaming,
  streamingStatus,
  streamingContent,
  error: aiError,
  sendMessage,
  acceptProposedChanges,
  rejectProposedChanges,
  clearConversation,
  planDiff,
} = useAIPlanEdit(patient.id, workouts);

// State for exercise detail dialog
const [selectedExercise, setSelectedExercise] = useState<Exercise | null>(null);

// Toggle AI Assistant sidebar
const [showAIAssistant, setShowAIAssistant] = useState(false);

Conditional Content Rendering

{/* Show streaming preview when AI is processing */}
{isStreaming && conversationHistory.length > 0 ? (
  <AIStreamingPreview
    streamingContent={streamingContent}
    streamingStatus={streamingStatus}
    currentPlanDaysCount={workouts.size}
  />
) : proposedPlan && planDiff ? (
  /* Show Comparison View when there's a proposed plan */
  <PlanComparisonView
    currentPlan={workouts}
    proposedPlan={proposedPlan}
    changeOverview={changeOverview}
    explanation={explanation}
    onAccept={handleAcceptAIChanges}
    onReject={rejectProposedChanges}
    getDateForDay={getDateForDay}
    onExerciseClick={(exerciseId, exerciseName) => {
      setSelectedExercise({
        id: exerciseId,
        exerciseId: exerciseId,  // Required for auto-fetch in dialog
        name: exerciseName
      } as any);
    }}
  />
) : (
  /* Normal editing view */
  <>
    <PlanDaySelector ... />
    <DayWorkoutPreview ... />
  </>
)}

Exercise Detail Dialog

{selectedExercise && (
  <ExerciseDetailDialog
    exercise={selectedExercise}
    onClose={() => setSelectedExercise(null)}
    onEdit={() => {}}
    onDelete={() => {}}
    onToggleFavorite={() => {}}
    showEditDeleteFavorite={false}
    userId={0}
    isPendingFavorite={false}
  />
)}

Layout with AI Sidebar

┌────────────────────────────────────────────────────────────────────────┐
│ Header: Edit Plan for [Patient Name]              [AI Assistant] [X]  │
├────────────────────────────────────────┬───────────────────────────────┤
│                                        │                               │
│  Main Content Area                     │   AI Assistant Sidebar        │
│                                        │   (380px wide)                │
│  When isStreaming:                     │                               │
│  ┌──────────────────────────────────┐  │   ┌───────────────────────┐  │
│  │ AIStreamingPreview               │  │   │ AIPlanEditChat        │  │
│  │ - Skeleton cards                 │  │   │                       │  │
│  │ - Progressive loading            │  │   │ - Messages            │  │
│  └──────────────────────────────────┘  │   │ - Streaming preview   │  │
│                                        │   │ - Input               │  │
│  When proposedPlan exists:             │   │ - Quick actions       │  │
│  ┌──────────────────────────────────┐  │   └───────────────────────┘  │
│  │ PlanComparisonView               │  │                               │
│  │ - Before/After cards             │  │                               │
│  │ - Exercise thumbnails            │  │                               │
│  │ - Accept/Reject buttons          │  │                               │
│  └──────────────────────────────────┘  │                               │
│                                        │                               │
│  Otherwise:                            │                               │
│  ┌──────────────────────────────────┐  │                               │
│  │ PlanDaySelector + DayWorkoutPreview│                               │
│  └──────────────────────────────────┘  │                               │
│                                        │                               │
├────────────────────────────────────────┴───────────────────────────────┤
│ Footer: [Cancel]                                            [Submit]   │
└────────────────────────────────────────────────────────────────────────┘

Data Flow

Complete Request-Response Flow

1. THERAPIST INTERACTION
   ├── Opens EditPlanModal
   ├── Clicks "AI Assistant" button
   └── Types: "Remove all dumbbell exercises and make workouts easier"

2. FRONTEND PROCESSING
   ├── AIPlanEditChat captures input
   ├── Calls onSendMessage(message)
   └── useAIPlanEdit.sendMessage() executes

3. API REQUEST
   ├── POST /v2/ai/therapist-plan-edit
   ├── Body: { patientId, currentPlan, userMessage, conversationHistory }
   └── Headers: Authorization token, Content-Type

4. BACKEND PROCESSING
   ├── Validate therapist authentication
   ├── Verify patient access (doctorPatientConnection)
   ├── Fetch available exercises (public + custom)
   ├── Get patient attributes for context
   ├── Build system prompt with:
   │   ├── Current plan structure
   │   ├── Available exercises list
   │   ├── Patient context (age, conditions, goals)
   │   └── Therapist's request
   └── Call OpenAI API

5. OPENAI PROCESSING
   ├── Model: GPT-5-mini-2025-08-07
   ├── Schema: PTTherapistPlanEditSchema
   └── Returns structured JSON response (streaming)

6. SSE STREAMING WITH SECTION EXTRACTION
   ├── Backend accumulates JSON chunks
   ├── Extracts sections via regex:
   │   ├── can_make_changes (boolean, early)
   │   ├── change_overview (string)
   │   ├── explanation (string)
   │   └── workouts_count (progressive count)
   ├── Emits section events as content is extracted
   └── Frontend updates streamingContent state

7. FRONTEND STREAMING UI
   ├── isStreaming = true triggers special views
   ├── AIPlanEditChat shows streaming content in chat bubble
   ├── EditPlanModal shows AIStreamingPreview full-page
   │   ├── Displays change_overview, explanation
   │   ├── Shows skeleton cards for days
   │   └── Cards light up as workouts_count increases
   └── User sees progressive feedback

8. RESPONSE PROCESSING (on "done" event)
   ├── transformAIResponseToWorkouts()
   │   └── Converts AI format → Map<number, EditDayWorkout>
   ├── calculatePlanDiff()
   │   └── Compares current vs proposed
   └── Update hook state (proposedPlan, planDiff, etc.)

9. UI UPDATE
   ├── EditPlanModal detects proposedPlan
   ├── Renders PlanComparisonView
   │   ├── Fetches exercise details for thumbnails
   │   ├── Shows Before/After cards
   │   └── Exercises are clickable
   └── Shows: Accept/Reject buttons

10. EXERCISE DETAIL VIEWING
    ├── User clicks exercise thumbnail
    ├── setSelectedExercise({ id, exerciseId, name })
    ├── ExerciseDetailDialog opens
    │   ├── Auto-fetches complete exercise data
    │   ├── Shows video player
    │   └── Shows description and tags
    └── Close returns to comparison view

11. THERAPIST DECISION
    ├── ACCEPT: handleAcceptAIChanges()
    │   ├── Apply proposedPlan to workouts state
    │   └── Clear diff view
    └── REJECT: rejectProposedChanges()
        ├── Clear proposedPlan
        └── Keep conversation for refinement

12. FINAL SAVE (if accepted)
    ├── Therapist clicks Submit
    └── Saves all workout changes to backend

Diff Calculation Logic

// For each day in the proposed plan:
for (const [dayNumber, proposedWorkout] of proposedPlan) {
  const currentWorkout = currentPlan.get(dayNumber);

  // Build exercise ID sets
  const currentIds = new Set(currentWorkout?.exercises.map(e => e.exerciseId));
  const proposedIds = new Set(proposedWorkout.exercises.map(e => e.exerciseId));

  // Detect changes
  for (const exercise of proposedWorkout.exercises) {
    if (!currentIds.has(exercise.exerciseId)) {
      // ADDED: exercise exists in proposed but not current
      changes.push({ type: "added", ... });
    } else {
      // Check for modifications
      const currentExercise = currentWorkout.exercises.find(...);
      const modifications = [];
      if (currentExercise.sets !== exercise.sets)
        modifications.push(`sets: ${currentExercise.sets}${exercise.sets}`);
      // ... check reps, duration, weight, exerciseBlock

      if (modifications.length > 0) {
        changes.push({ type: "modified", changes: modifications, ... });
      }
    }
  }

  // REMOVED: exercises in current but not in proposed
  for (const exercise of currentWorkout.exercises) {
    if (!proposedIds.has(exercise.exerciseId)) {
      changes.push({ type: "removed", ... });
    }
  }
}

Files Created/Modified

New Files

File Path Description
OktaPT-API/lib/openai_response_formats/pt_therapist_plan_edit_schema.ts OpenAI structured response schema
OktaPT-FE/lib/hooks/useAIPlanEdit.ts React hook for AI plan editing state, SSE handling, and streaming content
OktaPT-FE/lib/hooks/useExerciseDetails.ts Hook for batch fetching exercise details with caching
OktaPT-FE/lib/types/aiPlanEdit.ts TypeScript interfaces for AI plan editing
OktaPT-FE/components/plan-creation/AIPlanEditChat.tsx Chat interface component with streaming preview
OktaPT-FE/components/plan-creation/PlanComparisonView.tsx Before/after workout comparison with thumbnails
OktaPT-FE/components/plan-creation/AIStreamingPreview.tsx Full-page loading state with skeleton cards

Modified Files

File Path Changes
OktaPT-API/src/routes/ai/controllers.ts Added therapistPlanEdit endpoint with section extraction during streaming; enhanced context with workout history, performance trends, medical context; added AddUserAttributesToAiModel function for attribute extraction
OktaPT-API/lib/openai_response_formats/pt_therapist_plan_edit_schema.ts Added user_attributes field to response schema
OktaPT-FE/components/plan-creation/EditPlanModal.tsx Integrated AI assistant sidebar, streaming preview, comparison view, and exercise detail dialog

Usage Example

Therapist Workflow

  1. Open Plan Editor
  2. Navigate to patient's plan
  3. Click "Edit Plan"

  4. Activate AI Assistant

  5. Click "AI Assistant" button in header
  6. Sidebar appears on right

  7. Make Request

  8. Type: "Make this plan easier for someone recovering from knee surgery"
  9. Or use quick action: "Make easier"

  10. Watch Progressive Loading

  11. Main area shows AIStreamingPreview
  12. See "AI is analyzing your request..." initially
  13. change_overview appears as AI decides
  14. Skeleton cards light up as workouts are built

  15. Review Changes

  16. AI responds with modified plan
  17. PlanComparisonView shows all changes:

    • Before card: original exercises with thumbnails
    • After card: proposed exercises with thumbnails
    • NEW badge: green, for added exercises
    • CHANGED badge: amber, for modified exercises
  18. View Exercise Details

  19. Click any exercise thumbnail
  20. ExerciseDetailDialog opens
  21. Watch video, read description
  22. Close to return to comparison

  23. Accept or Refine

  24. Accept: Changes applied to plan
  25. Reject: Return to chat, refine request
  26. Example refinement: "Keep the leg exercises but reduce sets"

  27. Save

  28. Click "Submit" to save all changes
  29. Plan updated in database

Security Considerations

  1. Authentication: All requests require valid therapist JWT token
  2. Authorization: Backend verifies therapist has access to patient via doctorPatientConnection
  3. Input Validation: User messages sanitized before AI processing
  4. Exercise Restrictions: AI can only use exercises from approved list (public + therapist's custom)
  5. No Direct Database Writes: AI only proposes changes; therapist must explicitly accept and save