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¶
- Authentication: Requires authenticated therapist via middleware
- Authorization: Verifies therapist-patient relationship via
doctorPatientConnectiontable - Data Retrieval:
- Fetches public exercises + therapist's custom exercises
- Retrieves patient attributes for personalization context
- AI Integration:
- Uses GPT-5-mini-2025-08-07 model
- Implements heartbeat mechanism to keep connection alive
- Extracts and emits section events during streaming
- 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 } },
},
},
},
})
Performance Trends Calculation¶
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¶
- Chat Interface
- Header with title and close button
- Scrollable message area with auto-scroll
-
Text input with Enter to send, Shift+Enter for newline
-
Quick Action Buttons
- Dynamically generated based on current plan analysis
- Examples: "Make easier", "Make harder", "Add warm-up", "Remove dumbbells"
-
Analyzes exercise names to detect equipment used
-
Message Rendering
- User messages: right-aligned, blue background
- Assistant messages: left-aligned, white background
-
Follow-up questions rendered as clickable suggestions
-
Streaming Content Preview
- Shows change_overview text as it streams
- Displays explanation text
- Shows workout count progress with spinner
- 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¶
- Header with Streaming Indicator
- Sparkles icon with animated ping
- Shows change_overview and explanation as they stream
-
Progress indicators with bouncing dots
-
Skeleton Workout Cards
- Shows placeholder cards for expected days
- Cards "light up" as workouts are detected (via workouts_count)
-
Exercise row skeletons with animated pulse
-
Progressive Loading
- Cards transition from gray (loading) to brand colors (ready)
- 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¶
- Exercise Thumbnails
- Uses
useExerciseDetailshook to fetch complete exercise data - Displays video thumbnails with play icon overlay
-
Fallback to placeholder for exercises without media
-
Change Indicators
- NEW badge: green, for added exercises
- CHANGED badge: amber, for modified exercises
-
Removed exercises shown in "before" but not "after"
-
Clickable Exercises
- Click to open ExerciseDetailDialog
- 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¶
- Open Plan Editor
- Navigate to patient's plan
-
Click "Edit Plan"
-
Activate AI Assistant
- Click "AI Assistant" button in header
-
Sidebar appears on right
-
Make Request
- Type: "Make this plan easier for someone recovering from knee surgery"
-
Or use quick action: "Make easier"
-
Watch Progressive Loading
- Main area shows AIStreamingPreview
- See "AI is analyzing your request..." initially
- change_overview appears as AI decides
-
Skeleton cards light up as workouts are built
-
Review Changes
- AI responds with modified plan
-
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
-
View Exercise Details
- Click any exercise thumbnail
- ExerciseDetailDialog opens
- Watch video, read description
-
Close to return to comparison
-
Accept or Refine
- Accept: Changes applied to plan
- Reject: Return to chat, refine request
-
Example refinement: "Keep the leg exercises but reduce sets"
-
Save
- Click "Submit" to save all changes
- Plan updated in database
Security Considerations¶
- Authentication: All requests require valid therapist JWT token
- Authorization: Backend verifies therapist has access to patient via
doctorPatientConnection - Input Validation: User messages sanitized before AI processing
- Exercise Restrictions: AI can only use exercises from approved list (public + therapist's custom)
- No Direct Database Writes: AI only proposes changes; therapist must explicitly accept and save