diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11fa15c..181ae3d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,6 +2,14 @@ FitMop is a full-stack fitness analytics and workout planning platform. It follows a decoupled Client-Server architecture with a FastAPI backend and a Vue.js frontend, orchestrated by a single startup script. +## Core Technology Stack & Tooling + +> [!IMPORTANT] +> **Adherence to these tools is mandatory.** +> - **Backend Package Manager**: [`uv`](https://github.com/astral-sh/uv) (do NOT use pip directly). +> - **AI SDK**: [`google-genai`](https://pypi.org/project/google-genai/) (Standard Python SDK for Gemini). +> - **Frontend Tooling**: `Vite` + `npm`. + ## System Overview ```mermaid @@ -124,4 +132,4 @@ sequenceDiagram ## Security & Reliability - **CORS**: Restricted to localhost:5173. - **Error Handling**: Global FastAPI handler ensures API never crashes silently. -- **Pre-flight**: `fitmop.sh` checks for mandatory environment variables before launch. +- **Pre-flight**: `Makefile` checks for mandatory environment variables before launch. diff --git a/GEMINI.md b/GEMINI.md index b8cffed..87694b7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -3,7 +3,7 @@ This document provides a set of global instructions and principles for the Gemini CLI to follow during our interactions. **Reference:** -- **[Project Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)**: ALWAYS refer to this document for the technical layout and data flows of the system. +- **[Project Architecture](file:///Users/moritz/src/fitness_antigravity/ARCHITECTURE.md)**: CRITICAL: You MUST read this file at the start of every session to understand the enforced tooling (`uv`, `google-genai`) and architectural patterns. ## Environment Management - **Startup Rule:** ALWAYS start the application using `make run`. NEVER try to start individual services manually or use the old `fitmop.sh`. diff --git a/backend/src/common/env_manager.py b/backend/src/common/env_manager.py index 2e1b90c..a610338 100644 --- a/backend/src/common/env_manager.py +++ b/backend/src/common/env_manager.py @@ -1,5 +1,5 @@ import os -from typing import Any, Dict +from typing import Any, Dict, List from dotenv import load_dotenv, set_key @@ -34,7 +34,7 @@ class EnvManager: # Reload after setting self.load_service_env(service, override=True) - def get_status(self, service: str, required_keys: list[str]) -> Dict[str, Any]: + def get_status(self, service: str, required_keys: List[str]) -> Dict[str, Any]: """Check if required keys are set for a service.""" # Reload just in case self.load_service_env(service) diff --git a/backend/src/recommendations/engine.py b/backend/src/recommendations/engine.py index ed06ab6..285de62 100644 --- a/backend/src/recommendations/engine.py +++ b/backend/src/recommendations/engine.py @@ -89,8 +89,19 @@ class RecommendationEngine: Validation Rules: - SportTypes: RUNNING=1, CYCLING=2 - StepTypes: WARMUP=1, COOLDOWN=2, INTERVAL=3, RECOVERY=4, REST=5, REPEAT=6 - - EndCondition: DISTANCE=1, TIME=2, LAP_BUTTON=7 + - EndCondition: DISTANCE=1, TIME=2, LAP_BUTTON=7, ITERATIONS=7 - TargetType: NO_TARGET=1, HEART_RATE=2, PACE=4 (Speed) + + CRITICAL SCHEMA RULE: + Steps MUST use nested objects for types. Do NOT use flat IDs. + Example Step: + { + "type": "ExecutableStepDTO", + "stepOrder": 1, + "stepType": { "stepTypeId": 1, "stepTypeKey": "warmup" }, + "endCondition": { "conditionTypeId": 2, "conditionTypeKey": "time" }, + "endConditionValue": 600 + } """ user_prompt = f"User Request: {prompt}" diff --git a/backend/src/test_ai_modifier.py b/backend/src/test_ai_modifier.py new file mode 100644 index 0000000..920e2be --- /dev/null +++ b/backend/src/test_ai_modifier.py @@ -0,0 +1,102 @@ + +import json +import os +import sys +from typing import Dict, Any + +# Ensure we can import from src +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from common.env_manager import EnvManager +from garmin.workout_manager import WorkoutManager + +def load_sample_workout() -> Dict[str, Any]: + """Create a minimal valid workout for testing.""" + return { + "workoutName": "Test Workout", + "description": "Base for AI test", + "sportType": { + "sportTypeId": 1, + "sportTypeKey": "running" + }, + "workoutSegments": [ + { + "segmentOrder": 1, + "sportType": { + "sportTypeId": 1, + "sportTypeKey": "running" + }, + "workoutSteps": [ + { + "type": "ExecutableStepDTO", + "stepOrder": 1, + "stepTypeId": 1, + "childStepId": None, + "endConditionId": 2, + "endConditionValue": 300, + "targetTypeId": 1, + "targetValueOne": 10.0, + "targetValueTwo": 12.0 + } + ] + } + ] + } + +def test_ai_modification(): + print("\n--- Testing AI Workout Modifier ---") + + # 1. Setup Environment + root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")) + env = EnvManager(root_dir) + env.load_service_env("gemini") + + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + print("SKIP: GEMINI_API_KEY not found in environment.") + return + + # 2. Prepare Data + manager = WorkoutManager() + original_workout = load_sample_workout() + + # 3. specific instruction + prompt = "Add a 10 minute cooldown at the end." + print(f"Prompt: {prompt}") + + # 4. Execute AI Modification + try: + modified_workout = manager.generate_workout_json(prompt, existing_workout=original_workout) + + # 5. Verify Structure (Schema Validation) + errors = manager.validate_workout_json(modified_workout) + if errors: + print(f"FAIL: Schema Validation Errors: {json.dumps(errors, indent=2)}") + print(f"Invalid Workout JSON: {json.dumps(modified_workout, indent=2)}") + return + + # 6. Verify Content Logic + segments = modified_workout.get("workoutSegments", []) + if not segments: + print("FAIL: No segments found in modified workout.") + return + + steps = segments[0].get("workoutSteps", []) + last_step = steps[-1] if steps else None + + # Check if last step looks like a cooldown + # stepTypeId 4 = Cooldown (usually, or we check description/type) + is_cooldown = last_step and (last_step.get("stepTypeId") == 4 or "cool" in str(last_step).lower()) + + if is_cooldown: + print("PASS: Cooldown added successfully.") + print(f"Modified Steps Count: {len(steps)}") + else: + print("WARN: Could not strictly verify cooldown type, but schema is valid.") + print(f"Last Step: {json.dumps(last_step, indent=2)}") + + except Exception as e: + print(f"FAIL: Exception during AI processing: {e}") + +if __name__ == "__main__": + test_ai_modification() diff --git a/frontend/src/components/AiChatModal.vue b/frontend/src/components/AiChatModal.vue new file mode 100644 index 0000000..3f8b10e --- /dev/null +++ b/frontend/src/components/AiChatModal.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/components/WorkoutCompareView.vue b/frontend/src/components/WorkoutCompareView.vue new file mode 100644 index 0000000..34fe830 --- /dev/null +++ b/frontend/src/components/WorkoutCompareView.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/components/WorkoutVisualEditor.vue b/frontend/src/components/WorkoutVisualEditor.vue index 7a62d42..1f2fe84 100644 --- a/frontend/src/components/WorkoutVisualEditor.vue +++ b/frontend/src/components/WorkoutVisualEditor.vue @@ -3,9 +3,22 @@ import draggable from 'vuedraggable' import { GripVertical, Trash2, Plus, Repeat } from 'lucide-vue-next' const props = defineProps({ - modelValue: { type: Object, default: () => ({}) }, // Only used at top level - steps: { type: Array, default: () => [] }, // Used for recursion - isNested: { type: Boolean, default: false } + modelValue: { + type: Object, + default: () => ({}) + }, + steps: { + type: Array, + required: true + }, + readonly: { + type: Boolean, + default: false + }, + isNested: { + type: Boolean, + default: false + } }) const emit = defineEmits(['update:modelValue', 'update:steps']) @@ -101,9 +114,11 @@ const getStepColor = (typeId) => { type="text" placeholder="Workout Name" class="bare-input" + :class="{ 'opacity-50 cursor-not-allowed': readonly }" style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500" + :readonly="readonly" @input=" - emit('update:modelValue', { + !readonly && emit('update:modelValue', { ...modelValue, workoutName: $event.target.value }) @@ -114,8 +129,11 @@ const getStepColor = (typeId) => {