All files / src/tools lookup-exercises.ts

93.33% Statements 56/60
76.19% Branches 16/21
100% Functions 1/1
93.33% Lines 56/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163                                                                                                                    1x 1x                           13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x   13x                                 13x 13x 13x 13x 13x 13x   13x 1x 1x     13x 13x   13x       13x     13x   9x 9x 9x 9x 9x 9x 9x     4x 4x 4x 4x 4x   12x       13x 13x   13x 2x 2x 2x 2x 2x   2x 2x 2x 2x 13x 13x 13x  
/**
 * lookup_exercises computation tool.
 *
 * Queries the exercise library filtered by available equipment and movement
 * pattern. Optionally filters by difficulty tier and athlete familiarity
 * readiness. Returns rows suitable for use as exercise_id values in
 * submit_workout_plan.
 *
 * This tool requires a Supabase client + athleteId context. Pure tools ignore
 * the context parameter; this one requires it and returns isError when absent.
 */
 
import type { SupabaseClient } from '@supabase/supabase-js';
 
// ── Context type ─────────────────────────────────────────────
 
/**
 * Execution context required by DB-backed tools.
 * Exported so both the Node (packages/ai) and Deno (supabase/functions)
 * ports can type the context identically.
 */
export interface LookupExercisesContext {
  supabase: SupabaseClient;
  athleteId: string;
}
 
// ── Input / Output types ─────────────────────────────────────
 
export type MovementPatternFilter = 'squat' | 'hinge' | 'push' | 'pull';
export type DifficultyTier = 'foundational' | 'intermediate' | 'advanced' | 'specialist';
export type ReadinessLevel = 'confident' | 'familiar' | 'unknown';
 
export interface LookupExercisesInput {
  available_equipment: string[];
  movement_pattern: MovementPatternFilter;
  difficulty_tier?: DifficultyTier;
  readiness?: ReadinessLevel[];
  limit?: number;
}
 
export interface ExerciseRow {
  id: string;
  name: string;
  movement_patterns: string[];
  difficulty_tier: string;
  equipment_required: string[];
  domain: string | null;
}
 
export interface LookupExercisesOutput {
  exercises: ExerciseRow[];
  truncated?: boolean;
  total_available?: number;
  note?: string;
}
 
// ── Config ───────────────────────────────────────────────────
 
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
 
// ── Implementation ───────────────────────────────────────────
 
/**
 * Look up exercises matching the given equipment and movement pattern.
 *
 * Returns exercises whose equipment_required is a subset of available_equipment
 * (i.e. the athlete can perform every exercise with their available kit).
 *
 * When readiness is provided, only exercises the athlete has assessed at those
 * readiness levels are returned. If the result is empty after the readiness
 * filter, a recovery note is included so the model can retry without the filter.
 */
export async function lookupExercises(
  input: LookupExercisesInput,
  context: LookupExercisesContext,
): Promise<LookupExercisesOutput> {
  const { supabase, athleteId } = context;
  const {
    available_equipment,
    movement_pattern,
    difficulty_tier,
    readiness,
  } = input;
 
  const limit = Math.min(input.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
 
  // ── Base query: non-custom exercises whose movement_patterns array
  //   contains the requested pattern (PostgREST `cs` operator with JSON array).
  // ── equipment_required @> available_equipment is backwards — we want
  //   exercises where EVERY required piece is in available_equipment,
  //   i.e. available_equipment @> equipment_required.
  //   PostgREST: column.cs(value) translates to column @> value.
  //   So we call .contains('equipment_required', ...) — but that's
  //   equipment_required @> available_equipment (does required contain all
  //   available?), which is wrong. We need the OPPOSITE:
  //   available_equipment @> equipment_required, which in PostgREST is the
  //   `cd` (contained-by) operator on equipment_required:
  //     equipment_required.cd(available_equipment)
  //   PostgREST `cd` filter: column is contained by value.
  //   supabase-js: .filter('equipment_required', 'cd', JSON.stringify(available_equipment))
 
  let query = supabase
    .from('exercises')
    .select('id, name, movement_patterns, difficulty_tier, equipment_required, domain')
    .eq('is_custom', false)
    .filter('movement_patterns', 'cs', JSON.stringify([movement_pattern]))
    .filter('equipment_required', 'cd', JSON.stringify(available_equipment));
 
  if (difficulty_tier) {
    query = query.eq('difficulty_tier', difficulty_tier);
  }
 
  // Fetch a generous batch for counting and readiness filtering
  const fetchLimit = readiness ? MAX_LIMIT * 2 : limit;
  const { data: baseRows, error } = await query.limit(fetchLimit);
 
  if (error) {
    throw new Error(`Failed to query exercises: ${error.message}`);
  }
 
  const rows: ExerciseRow[] = (baseRows ?? []) as ExerciseRow[];
 
  // ── Readiness filter ──────────────────────────────────────
  if (!readiness || readiness.length === 0) {
    // No readiness filter: return all matching exercises (sparse-profile safe)
    const total = rows.length;
    const sliced = rows.slice(0, limit);
    return {
      exercises: sliced,
      ...(total > limit ? { truncated: true, total_available: total } : {}),
    };
  }
 
  // Fetch familiarity rows for this athlete at the requested readiness levels
  const { data: familiarityRows, error: famError } = await supabase
    .from('exercise_familiarities')
    .select('exercise_id')
    .eq('athlete_id', athleteId)
    .in('readiness', readiness);
 
  if (famError) {
    throw new Error(`Failed to query exercise_familiarities: ${famError.message}`);
  }
 
  const familiarIds = new Set((familiarityRows ?? []).map((r: { exercise_id: string }) => r.exercise_id));
  const filtered = rows.filter((r) => familiarIds.has(r.id));
 
  if (filtered.length === 0) {
    return {
      exercises: [],
      note: `athlete has no exercise_familiarities at the requested readiness (${readiness.join(', ')}); consider calling without readiness for a broader list`,
    };
  }
 
  const total = filtered.length;
  const sliced = filtered.slice(0, limit);
  return {
    exercises: sliced,
    ...(total > limit ? { truncated: true, total_available: total } : {}),
  };
}