diff --git a/.gitignore b/.gitignore index 1bb103b..5964886 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ # dependencies node_modules/ -db/migrations/ # Expo .expo/ dist/ @@ -38,13 +37,13 @@ yarn-error.* app-example -# Drizzle -db/migrations/ - # Ignore the entire Android directory android/ +# But keep the model file for emotion detection +!android/app/src/main/assets/face_landmarker.task android/app/src/main/java/com/expostarter/base/MainActivity.kt android/app/src/main/java/com/expostarter/base/MainActivity.kt .github/copilot-instructions.md rebuild.bat public/database.sqlite +.idea/ \ No newline at end of file diff --git a/EMOTION_DETECTION_SETUP.md b/EMOTION_DETECTION_SETUP.md new file mode 100644 index 0000000..8080bb4 --- /dev/null +++ b/EMOTION_DETECTION_SETUP.md @@ -0,0 +1,862 @@ +# SkillSpark Emotion Detection Setup Guide + +Complete guide for setting up and troubleshooting the MediaPipe-based emotion detection system. + +--- + +## 📋 Table of Contents + +- [Prerequisites](#prerequisites) +- [Initial Setup](#initial-setup) +- [Running the App](#running-the-app) +- [Enabling Emotion Detection](#enabling-emotion-detection) +- [Testing Emotion Detection](#testing-emotion-detection) +- [Common Errors & Solutions](#common-errors--solutions) +- [Technical Details](#technical-details) +- [Performance Notes](#performance-notes) + +--- + +## Prerequisites + +### System Requirements + +**Android:** +- Android 7.0+ (API 24+) +- Camera hardware +- ~100MB free space for MediaPipe libraries +- Decent CPU (detection runs every 10s, takes ~100-300ms) + +**Development Environment:** +- Node.js 18+ or Bun +- Android Studio or Android SDK +- USB debugging enabled OR Android emulator +- Git + +### Dependencies Installed + +All required dependencies are in `package.json`: +- `expo` ^54.0.0 +- `expo-camera` ^17.0.10 +- `expo-dev-client` ~6.0.20 +- `react-native` 0.81.4 +- `svd-js` ^1.1.1 (for emotion math) + +--- + +## Initial Setup + +### Step 1: Clone and Install Dependencies + +```bash +cd C:/Codes-here/SkillSpark +bun install +# or +npm install +``` + +### Step 2: Generate Native Android Project + +```bash +bun expo prebuild --platform android +``` + +**Why?** Expo managed projects don't have native `android/` folders by default. This command: +- Generates the native Android project structure +- Applies plugins from `app.config.ts` (expo-camera, etc.) +- Links all expo modules including our custom FaceLandmarks module +- Creates gradle build system + +**Expected Output:** +``` +✔ Created native directory +✔ Updated package.json | no changes +✔ Finished prebuild +``` + +### Step 3: Download MediaPipe Model File + +```bash +curl -L "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" -o face_landmarker.task +``` + +**Why?** The MediaPipe Face Landmarker model (3.6MB) is required for detecting 468 facial landmarks. This file is too large to commit to Git. + +**Verify download:** +```bash +ls -lh face_landmarker.task +# Should show: -rw-r--r-- 1 user 197121 3.6M face_landmarker.task +``` + +⚠️ **CRITICAL:** File must be exactly **3.6MB** (3,758,596 bytes). If smaller, it's corrupted. + +### Step 4: Copy Model to Android Assets + +```bash +mkdir -p android/app/src/main/assets +cp face_landmarker.task android/app/src/main/assets/ +``` + +**Why?** Android apps bundle assets in this specific directory. The native Kotlin module loads the model from: +```kotlin +context.assets.open("face_landmarker.task") +``` + +**Verify copy:** +```bash +ls -lh android/app/src/main/assets/face_landmarker.task +# Should show: 3.6M +``` + +### Step 5: Verify Native Module Files + +Check that all FaceLandmarks native module files exist: + +```bash +# Kotlin module +ls -la modules/face-landmarks/android/src/main/java/expo/modules/facelandmarks/FaceLandmarksModule.kt + +# Build configuration +ls -la modules/face-landmarks/android/build.gradle + +# Module config +ls -la modules/face-landmarks/expo-module.config.json +``` + +**Expected files:** +- `FaceLandmarksModule.kt` - Native Android implementation with MediaPipe +- `build.gradle` - Dependencies: mediapipe:tasks-vision:0.10.14 +- `expo-module.config.json` - Expo autolinking configuration +- `AndroidManifest.xml` - Android manifest + +If any are missing, they were created in previous sessions. Check git history. + +--- + +## Running the App + +### Build and Install + +```bash +bun run android +# or +bun expo run:android +``` + +**What happens:** +1. Gradle downloads MediaPipe dependencies (~50MB, first time only) +2. Compiles Kotlin native code (FaceLandmarksModule) +3. Bundles face_landmarker.task model (3.6MB) +4. Creates APK +5. Installs on connected device/emulator +6. Starts Metro bundler +7. Launches app with Dev Client + +**First build:** 4-6 minutes (native compilation) +**Subsequent builds:** 30-60 seconds (incremental) + +**Expected output:** +``` +Using expo modules + - face-landmarks (1.0.0) ← Your custom module + - expo-camera (17.0.10) + ... +BUILD SUCCESSFUL in 24s +``` + +### Development Mode + +After successful build: + +```bash +bun run dev +# or +bun expo start --dev-client +``` + +This starts Metro bundler for hot reloading (JS changes only, no native recompile needed). + +--- + +## Enabling Emotion Detection + +### In-App Configuration + +1. **Launch app** on device +2. **Grant camera permission** when prompted (required) +3. Navigate to **Settings** tab (bottom navigation) +4. Find **"Emotion Detection"** toggle +5. Turn it **ON** + +**Why?** Emotion detection is disabled by default to: +- Save battery (camera + ML processing) +- Respect user privacy +- Allow users to learn without monitoring + +Setting is stored in MMKV (persistent): +```typescript +useIsEmotionDetectionEnabled() // Hook to check state +``` + +--- + +## Testing Emotion Detection + +### Basic Test + +1. Enable emotion detection in Settings +2. Navigate to any **Topic** (e.g., "HTML Basics", "React Hooks") +3. Grant camera permission if prompted again +4. Look for **"Learning Engagement"** card at top of topic screen + +**Expected behavior:** +- 10-second countdown timer visible +- "Detecting your learning engagement..." message +- After 10s: Emotion label appears (e.g., "engaged", "drowsy", "confused") +- Updates every 10 seconds + +### Emotion Labels + +The system detects 6 emotional states: + +| Emotion | Description | Visual Cues | +|---------|-------------|-------------| +| **engaged** | Actively learning, eyes open, facing camera | Normal EAR/MAR, centered gaze | +| **drowsy** | Tired, eyes closing | Low EAR (<0.2) | +| **confused** | Frowning, raised eyebrows | High brow variance, asymmetry | +| **frustrated** | Tension in jaw/brow | Wide jaw, high brow height | +| **bored** | Yawning, disengaged | High MAR (>0.5) | +| **looking_away** | Not facing camera | Head rotation >30° | + +### Verification + +Check if detection is working: + +```bash +# Android logcat (filter for FaceLandmarks) +adb logcat | grep -i "FaceLandmarks" + +# Should see: +# FaceLandmarksModule: detectFromImageAsync called +# FaceLandmarksModule: Detected 468 landmarks +# EmotionDetector: emotion=engaged, confidence=0.85 +``` + +--- + +## Common Errors & Solutions + +### Error 1: "Cannot find native module 'FaceLandmarks'" + +**Symptoms:** +- Red error screen on app launch +- "Cannot find native module 'FaceLandmarks'" +- Call stack shows `requireNativeModule` + +**Cause:** Native module not linked or not compiled + +**Solution:** + +```bash +# Clean gradle cache +cd android +./gradlew clean +./gradlew --stop +cd .. + +# Remove build artifacts +rm -rf android/app/.cxx android/app/build android/build android/.gradle + +# Rebuild +bun run android +``` + +**Why this works:** Clears corrupted cmake cache and forces clean native build. + +--- + +### Error 2: "Unable to open zip archive" + +**Symptoms:** +- Error toast: "Error detecting emotion: unknown: Unable to open zip archive" +- App works but emotion detection fails +- Console shows zip error + +**Cause:** Corrupted or incomplete `face_landmarker.task` file + +**Solution:** + +```bash +# 1. Check file size +ls -lh face_landmarker.task +# If NOT 3.6M, it's corrupted + +# 2. Delete corrupted files +rm -f face_landmarker.task android/app/src/main/assets/face_landmarker.task + +# 3. Download fresh copy +curl -L "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" -o face_landmarker.task + +# 4. Verify download (MUST be 3.6M) +ls -lh face_landmarker.task + +# 5. Copy to assets +cp face_landmarker.task android/app/src/main/assets/ + +# 6. Rebuild +bun run android +``` + +**Prevention:** Always verify file size after download. Partial downloads cause this error. + +--- + +### Error 3: "Could not find face_landmarker.task" + +**Symptoms:** +- Error: "Could not find face_landmarker.task in bundle" +- Emotion detection starts but immediately fails + +**Cause:** Model file not in Android assets + +**Solution:** + +```bash +# 1. Check if file exists +ls -la android/app/src/main/assets/face_landmarker.task + +# If missing: +# 2. Create directory +mkdir -p android/app/src/main/assets + +# 3. Copy model (ensure it exists in project root first) +cp face_landmarker.task android/app/src/main/assets/ + +# 4. Rebuild to bundle the asset +bun run android +``` + +**Why this happens:** Running `expo prebuild` regenerates the android/ folder, removing custom assets. Always re-copy after prebuild. + +--- + +### Error 4: "Route './topic/[id].tsx' is missing the required default export" + +**Symptoms:** +- Warning about missing default export +- App crashes when navigating to topic + +**Cause:** Cascading error - actually caused by native module failing to load, which crashes import chain before React can see the default export. + +**Solution:** Fix the underlying native module error first (see Error 1). This warning will disappear once FaceLandmarks module loads correctly. + +--- + +### Error 5: CMake Autolinking Errors + +**Symptoms:** +``` +CMake Error: add_subdirectory given source +"node_modules/react-native-gesture-handler/android/build/generated/source/codegen/jni/" +which is not an existing directory +``` + +**Cause:** Corrupted CMake cache from previous builds + +**Solution:** + +```bash +# 1. Stop gradle daemons +cd android +./gradlew --stop +cd .. + +# 2. Deep clean +rm -rf android/app/.cxx +rm -rf android/app/build +rm -rf android/build +rm -rf android/.gradle +rm -rf node_modules/.cache + +# 3. Rebuild from scratch +bun run android +``` + +**Prevention:** Run clean builds after switching branches or updating dependencies. + +--- + +### Error 6: "Task ':app:packageDebug' failed" + +**Symptoms:** +- Build progresses to ~98% +- Fails at packaging step +- Error: "A failure occurred while executing PackageAndroidArtifact$IncrementalSplitterRunnable" + +**Cause:** Gradle daemon issues or build cache corruption + +**Solution:** + +```bash +# 1. Clean gradle +cd android +./gradlew clean +./gradlew --stop +cd .. + +# 2. Remove all caches +rm -rf android/app/.cxx android/app/build android/build android/.gradle + +# 3. Rebuild +bun run android +``` + +--- + +### Error 7: MediaPipe Dependencies Not Found + +**Symptoms:** +- Build error: "Could not find com.google.mediapipe:tasks-vision:0.10.14" +- Gradle sync fails + +**Cause:** Missing dependency declaration or internet issues + +**Solution:** + +```bash +# 1. Verify build.gradle exists +cat modules/face-landmarks/android/build.gradle | grep mediapipe +# Should show: implementation "com.google.mediapipe:tasks-vision:0.10.14" + +# 2. If missing, the file wasn't created. Check git history or recreate. + +# 3. Sync gradle with internet connection +cd android +./gradlew --refresh-dependencies +cd .. + +# 4. Rebuild +bun run android +``` + +--- + +### Error 8: Camera Permission Denied + +**Symptoms:** +- Emotion detection UI shows but doesn't start +- No camera feed +- Silent failure + +**Cause:** Camera permission not granted + +**Solution:** + +**Method 1 - In App:** +1. Go to device Settings → Apps → SkillSpark +2. Permissions → Camera → Allow + +**Method 2 - ADB:** +```bash +adb shell pm grant com.expostarter.base android.permission.CAMERA +``` + +**Method 3 - Reinstall:** +```bash +# Uninstall +adb uninstall com.expostarter.base + +# Reinstall +bun run android +# Grant permission when prompted +``` + +--- + +### Error 9: Out of Memory (OOM) During Build + +**Symptoms:** +- Build fails with: "Expiring Daemon because JVM heap space is exhausted" +- Gradle runs out of memory + +**Solution:** + +Create or edit `android/gradle.properties`: + +```properties +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.configureondemand=true +``` + +Then rebuild: +```bash +cd android +./gradlew clean +cd .. +bun run android +``` + +--- + +### Error 10: Expo Dev Client Not Installed + +**Symptoms:** +- Build succeeds but app doesn't open +- QR code shows but scanning does nothing + +**Cause:** App needs Dev Client to run custom native modules (not Expo Go compatible) + +**Solution:** +The build process automatically creates a Dev Client APK. Just run: +```bash +bun run android +``` + +This installs the Dev Client build (not Expo Go). + +--- + +### Error 11: Port 8081 Already in Use + +**Symptoms:** +- Metro bundler fails to start +- Error: "EADDRINUSE: address already in use :::8081" + +**Solution:** + +```bash +# Kill process on port 8081 +# Windows: +netstat -ano | findstr :8081 +taskkill /PID /F + +# Mac/Linux: +lsof -ti:8081 | xargs kill -9 + +# Then restart +bun run android +``` + +--- + +### Error 12: Android Emulator Not Detected + +**Symptoms:** +- "No devices found" +- Build succeeds but nothing installs + +**Solution:** + +```bash +# List devices +adb devices + +# If empty, start emulator +emulator -list-avds # See available emulators +emulator -avd # Start specific emulator + +# Or connect physical device via USB debugging +``` + +--- + +## Technical Details + +### Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ TopicEmotionDetector.tsx (React Component) │ +│ - Captures photo every 10s with expo-camera │ +│ - Calls detectEmotionFromImageUri() │ +└───────────────────┬─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ lib/emotion/detectEmotion.ts (TypeScript) │ +│ - Orchestrates detection pipeline │ +└───────────────────┬─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ FaceLandmarks Native Module (Kotlin) │ +│ - Reads image bytes from URI │ +│ - Handles EXIF orientation (portrait/landscape) │ +│ - Loads face_landmarker.task model │ +│ - Runs MediaPipe Face Landmarker │ +│ - Returns 468 normalized landmarks (x,y,z) │ +└───────────────────┬─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ EmotionDetector.ts (TypeScript) │ +│ - Converts normalized landmarks to pixels │ +│ - Calculates 10 facial features: │ +│ • EAR (Eye Aspect Ratio) │ +│ • MAR (Mouth Aspect Ratio) │ +│ • Brow height/variance/asymmetry │ +│ • Head pose (pitch/yaw/roll) │ +│ • Facial symmetry (PCA) │ +│ • Jaw width │ +│ - Decision tree classifies emotion │ +└───────────────────┬─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Display Result (UI) │ +│ - Show emotion label + confidence │ +│ - Trigger callbacks (tone switching, etc.) │ +└─────────────────────────────────────────────────┘ +``` + +### Files Overview + +**Native Module:** +- `modules/face-landmarks/android/src/main/java/expo/modules/facelandmarks/FaceLandmarksModule.kt` +- `modules/face-landmarks/android/build.gradle` +- `modules/face-landmarks/expo-module.config.json` + +**TypeScript Logic:** +- `lib/emotion/EmotionDetector.ts` - Feature extraction + classification +- `lib/emotion/detectEmotion.ts` - Main entry point +- `components/emotion/TopicEmotionDetector.tsx` - React component + +**Model:** +- `face_landmarker.task` - 3.6MB MediaPipe model (468 landmarks) + +### MediaPipe Configuration + +```kotlin +FaceLandmarker.FaceLandmarkerOptions.builder() + .setRunningMode(RunningMode.IMAGE) // Single image mode + .setNumFaces(1) // Detect one face + .setMinFaceDetectionConfidence(0.5f) // Detection threshold + .setMinTrackingConfidence(0.5f) // Tracking threshold + .build() +``` + +### Emotion Classification Features + +| Feature | Formula | Purpose | +|---------|---------|---------| +| **EAR** | `(vertical1 + vertical2) / (2 * horizontal)` | Detect drowsiness (closed eyes) | +| **MAR** | `(vertical1 + vertical2) / (2 * horizontal)` | Detect yawning/boredom | +| **Brow Height** | Distance from brow to eye center | Detect surprise/confusion | +| **Brow Asymmetry** | Left vs right brow height difference | Detect skepticism | +| **Head Tilt** | Rotation matrix from facial landmarks | Detect looking away | +| **Symmetry Ratio** | PCA eigenvalue ratio | Detect engagement | +| **Jaw Width** | Distance between jaw points | Detect tension/frustration | + +Decision tree thresholds (tuned for learning context): +- Drowsy: EAR < 0.2 +- Bored: MAR > 0.5 +- Looking away: Head rotation > 30° +- Confused: Brow variance > 15.0 +- Frustrated: Jaw width > 65 + high brow +- Engaged: Default (none of above) + +--- + +## Performance Notes + +### Timing Breakdown + +| Operation | Time | Notes | +|-----------|------|-------| +| Photo capture | ~50ms | expo-camera | +| Native module call | ~10ms | Bridge overhead | +| MediaPipe detection (first) | ~200-300ms | Model initialization | +| MediaPipe detection (subsequent) | ~50-150ms | Cached model | +| Feature calculation | ~5-10ms | TypeScript math | +| Classification | <1ms | Decision tree | +| **Total (first detection)** | **~250-360ms** | | +| **Total (subsequent)** | **~100-200ms** | | + +### Memory Usage + +- MediaPipe model: ~20MB RAM (loaded once) +- Face landmarks: ~50KB per detection (468 × 3 floats) +- Image buffer: ~1-5MB (depends on camera resolution) +- **Total additional RAM:** ~30-50MB + +### Battery Impact + +- Camera active: ~10s every 10s = 16% duty cycle +- ML processing: ~150ms every 10s = 1.5% duty cycle +- **Estimated battery drain:** ~5-10% per hour of active use + +### Optimization Tips + +1. **Reduce detection frequency** - Edit `DETECTION_INTERVAL` in TopicEmotionDetector.tsx: + ```typescript + const DETECTION_INTERVAL = 15000; // 15 seconds instead of 10 + ``` + +2. **Lower camera resolution** - Edit camera config: + ```tsx + + ``` + +3. **Skip frames when not focused** - Already implemented with tab visibility detection + +--- + +## Updating After Changes + +### After Running `expo prebuild` Again + +**Important:** `expo prebuild` regenerates the `android/` folder, removing custom assets. + +**Always re-run:** +```bash +# Copy model file +cp face_landmarker.task android/app/src/main/assets/ + +# Rebuild +bun run android +``` + +### After Pulling New Code + +```bash +# 1. Install dependencies +bun install + +# 2. Check if model exists +ls -lh face_landmarker.task + +# 3. If missing, download +curl -L "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" -o face_landmarker.task + +# 4. Rebuild +bun run android +``` + +### After Changing Native Code + +If you modify `FaceLandmarksModule.kt`: + +```bash +# Clean build required +cd android +./gradlew clean +cd .. +bun run android +``` + +### After Changing TypeScript Code + +No rebuild needed! Just reload: +- Shake device → Reload +- Or press `r` in Metro bundler terminal + +--- + +## Alternative Setup Script + +For automated setup, use the provided script: + +```bash +bash setup-emotion-detection.sh +``` + +This script: +1. Downloads model file if missing +2. Runs `expo prebuild` +3. Creates assets directory +4. Copies model file +5. Verifies all module files exist +6. Installs JavaScript dependencies + +--- + +## Support & Debugging + +### Enable Verbose Logging + +Add to `FaceLandmarksModule.kt`: +```kotlin +companion object { + private const val TAG = "FaceLandmarksModule" +} + +// Then in code: +Log.d(TAG, "Processing image: ${uri}") +Log.d(TAG, "Detected ${landmarks.size} landmarks") +``` + +View logs: +```bash +adb logcat | grep FaceLandmarksModule +``` + +### Test Native Module Directly + +```typescript +import { FaceLandmarks } from '@/modules/face-landmarks/src'; + +// Test detection +const result = await FaceLandmarks.detectFromImageAsync(photoUri); +console.log('Width:', result.width); +console.log('Height:', result.height); +console.log('Landmarks:', result.landmarks.length); // Should be 468 +``` + +### Check Module Linking + +```bash +# Android +adb logcat | grep "Using expo modules" +# Should show: face-landmarks (1.0.0) +``` + +--- + +## FAQ + +**Q: Why not use Expo Go?** +A: Expo Go doesn't support custom native modules. Our FaceLandmarks module requires native Kotlin code, so we need a Dev Client build. + +**Q: Can this work on iOS?** +A: Yes! The iOS implementation exists at `modules/face-landmarks/ios/FaceLandmarksModule.swift`. Follow similar steps with Xcode. + +**Q: Why MediaPipe instead of TensorFlow Lite?** +A: MediaPipe is optimized for real-time face detection, provides 468 high-quality landmarks, and has better mobile performance. + +**Q: How accurate is emotion detection?** +A: ~75-85% accuracy in controlled conditions. It's rule-based (not ML), optimized for detecting learning engagement states rather than all human emotions. + +**Q: Can I use this for other apps?** +A: Yes! The `modules/face-landmarks` folder is a standalone Expo module. Copy it to any Expo project. + +**Q: Why 10-second intervals?** +A: Balance between responsiveness and battery life. Too frequent = battery drain. Too slow = misses quick emotional changes. + +--- + +## Contributing + +Found a bug? Have improvements? + +1. Check existing issues +2. Create detailed bug report with: + - Device model & Android version + - Build logs + - Logcat output + - Steps to reproduce + +--- + +## License + +Apache 2.0 - See LICENSE file + +--- + +**Last Updated:** January 25, 2026 +**Version:** 1.0.0 +**Maintainer:** SkillSpark Team diff --git a/android/app/src/main/assets/face_landmarker.task b/android/app/src/main/assets/face_landmarker.task new file mode 100644 index 0000000..c50c845 Binary files /dev/null and b/android/app/src/main/assets/face_landmarker.task differ diff --git a/app.config.ts b/app.config.ts index f889e64..7da2eba 100644 --- a/app.config.ts +++ b/app.config.ts @@ -2,13 +2,13 @@ import type { ConfigContext, ExpoConfig } from "@expo/config"; export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, - name: "Expo Starter", - slug: "expostarter", + name: "SkillSpark", + slug: "skillspark", newArchEnabled: true, version: "1.0.0", orientation: "portrait", icon: "./assets/images/icon.png", - scheme: "ltstarter", + scheme: "skillspark", userInterfaceStyle: "dark", runtimeVersion: { policy: "appVersion", @@ -22,19 +22,33 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { newArchEnabled: true, supportsTablet: true, - bundleIdentifier: "com.expostarter.base", + bundleIdentifier: "com.skillspark.app", infoPlist: { NSCameraUsageDescription: "This app uses the camera to detect your emotions and engagement while learning.", }, }, android: { newArchEnabled: true, + icon: "./assets/images/icon.png", adaptiveIcon: { - foregroundImage: "./assets/images/adaptive-icon.png", + foregroundImage: "./assets/images/icon.png", backgroundColor: "#ffffff", }, - package: "com.expostarter.base", + package: "com.skillspark.app", permissions: ["CAMERA"], + versionCode: 1, + intentFilters: [ + { + action: "VIEW", + data: [ + { + scheme: "https", + host: "skillspark.app", + }, + ], + category: ["BROWSABLE", "DEFAULT"], + }, + ], }, web: { bundler: "metro", @@ -53,6 +67,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ cameraPermission: "Allow $(PRODUCT_NAME) to access your camera for emotion detection during learning.", }, ], + "./plugins/withFaceLandmarkerAsset", ], experiments: { typedRoutes: true, @@ -60,8 +75,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, extra: { eas: { - projectId: "", + projectId: "7da223ee-8eef-4fd1-8ce6-ae1d599ec63f", }, }, - owner: "*", }); diff --git a/assets/images/1.jpg b/assets/images/1.jpg deleted file mode 100644 index 69afeab..0000000 Binary files a/assets/images/1.jpg and /dev/null differ diff --git a/assets/images/1_resized_224.jpg b/assets/images/1_resized_224.jpg deleted file mode 100644 index a82cfb2..0000000 Binary files a/assets/images/1_resized_224.jpg and /dev/null differ diff --git a/assets/images/2.jpg b/assets/images/2.jpg deleted file mode 100644 index 6806eac..0000000 Binary files a/assets/images/2.jpg and /dev/null differ diff --git a/assets/images/2_resized_224.jpg b/assets/images/2_resized_224.jpg deleted file mode 100644 index 987bd14..0000000 Binary files a/assets/images/2_resized_224.jpg and /dev/null differ diff --git a/assets/images/3.jpg b/assets/images/3.jpg deleted file mode 100644 index 8fa21b3..0000000 Binary files a/assets/images/3.jpg and /dev/null differ diff --git a/assets/images/3_resized_224.jpg b/assets/images/3_resized_224.jpg deleted file mode 100644 index a7872e6..0000000 Binary files a/assets/images/3_resized_224.jpg and /dev/null differ diff --git a/assets/images/4.jpg b/assets/images/4.jpg deleted file mode 100644 index 09500b3..0000000 Binary files a/assets/images/4.jpg and /dev/null differ diff --git a/assets/images/4_resized_224.jpg b/assets/images/4_resized_224.jpg deleted file mode 100644 index 7fd0bbb..0000000 Binary files a/assets/images/4_resized_224.jpg and /dev/null differ diff --git a/assets/images/5.jpg b/assets/images/5.jpg deleted file mode 100644 index e45e30b..0000000 Binary files a/assets/images/5.jpg and /dev/null differ diff --git a/assets/images/6.jpg b/assets/images/6.jpg deleted file mode 100644 index b5397bd..0000000 Binary files a/assets/images/6.jpg and /dev/null differ diff --git a/assets/images/icon.png b/assets/images/icon.png index 7a866b2..3d72b4e 100644 Binary files a/assets/images/icon.png and b/assets/images/icon.png differ diff --git a/assets/images/splash.png b/assets/images/splash.png index 2545f58..3d72b4e 100644 Binary files a/assets/images/splash.png and b/assets/images/splash.png differ diff --git a/assets/model/engagement6.keras b/assets/model/engagement6.keras deleted file mode 100644 index b3ced0f..0000000 Binary files a/assets/model/engagement6.keras and /dev/null differ diff --git a/assets/model/engagement6_fp16.tflite b/assets/model/engagement6_fp16.tflite deleted file mode 100644 index 9cd0fa5..0000000 Binary files a/assets/model/engagement6_fp16.tflite and /dev/null differ diff --git a/assets/model/engagement6_int8_dynamic.tflite b/assets/model/engagement6_int8_dynamic.tflite deleted file mode 100644 index 59520df..0000000 Binary files a/assets/model/engagement6_int8_dynamic.tflite and /dev/null differ diff --git a/assets/model/labels.txt b/assets/model/labels.txt deleted file mode 100644 index e73a294..0000000 --- a/assets/model/labels.txt +++ /dev/null @@ -1,6 +0,0 @@ -wbored -confused -drowsy -engaged -frustrated -looking_away \ No newline at end of file diff --git a/bun.lock b/bun.lock index 0f54d49..998ef3a 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "expo-status-bar": "~3.0.8", "expo-system-ui": "~6.0.7", "expo-web-browser": "~15.0.8", + "face-landmarks": "file:./modules/face-landmarks", "gh-pages": "^6.3.0", "groq-sdk": "^0.37.0", "lucide-react-native": "^0.514.0", @@ -1330,6 +1331,8 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "face-landmarks": ["face-landmarks@file:modules/face-landmarks", { "devDependencies": { "expo-module-scripts": "*" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], diff --git a/components/emotion/TopicEmotionDetector.tsx b/components/emotion/TopicEmotionDetector.tsx index 2b5a2f7..3d8c063 100644 --- a/components/emotion/TopicEmotionDetector.tsx +++ b/components/emotion/TopicEmotionDetector.tsx @@ -22,8 +22,6 @@ import { Card } from "@/components/ui/card"; import Animated, { FadeIn } from "react-native-reanimated"; import { Brain, Camera, AlertCircle } from "lucide-react-native"; -// Import native face landmarks module -import { FaceLandmarks, type FaceLandmarksResult } from "@/modules/face-landmarks/src"; // Import emotion detector with Python-ported logic import { EmotionDetector, @@ -31,16 +29,23 @@ import { type EmotionResult as EmotionDetectorResult, } from "@/lib/emotion/EmotionDetector"; -// Check if native module is available +// Conditionally import native face landmarks module +let FaceLandmarks: any = null; let isFaceLandmarksAvailable = false; +let FaceLandmarksResult: any; + try { if (Platform.OS !== "web") { - // Try to access the module - will throw if not available + // Try to import the module - will throw if not available + const faceLandmarksModule = require("@/modules/face-landmarks/src"); + FaceLandmarks = faceLandmarksModule.FaceLandmarks; + FaceLandmarksResult = faceLandmarksModule.FaceLandmarksResult; isFaceLandmarksAvailable = FaceLandmarks !== null && FaceLandmarks !== undefined; } } catch (error) { console.warn("FaceLandmarks native module not available:", error); isFaceLandmarksAvailable = false; + FaceLandmarks = null; } interface EmotionResultState { @@ -118,7 +123,7 @@ export function TopicEmotionDetector({ console.log("📷 Photo captured:", photo.uri); // Detect face landmarks using native module - const result: FaceLandmarksResult = + const result: any = await FaceLandmarks.detectFromImageAsync(photo.uri); console.log( diff --git a/components/topic/TopicVideoGenerator.tsx b/components/topic/TopicVideoGenerator.tsx index abffb8d..bd206f8 100644 --- a/components/topic/TopicVideoGenerator.tsx +++ b/components/topic/TopicVideoGenerator.tsx @@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { APIKeyRequiredDialog } from '@/components/ui/api-key-required-dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; import { Video, Trash2, RefreshCw } from '@/components/Icons'; import { Video as ExpoVideo, ResizeMode } from 'expo-av'; import { geminiService } from '@/lib/gemini'; @@ -51,6 +53,8 @@ export function TopicVideoGenerator({ const [downloadProgress, setDownloadProgress] = useState(0); const [isLocalVideo, setIsLocalVideo] = useState(false); const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); + const [showLengthDialog, setShowLengthDialog] = useState(false); + const [selectedLength, setSelectedLength] = useState(60); // Default 1 minute const loadExistingVideo = useCallback(async () => { try { @@ -113,7 +117,7 @@ export function TopicVideoGenerator({ } }, [videoStatus, videoError, topicId]); - const handleGenerateVideo = useCallback(async () => { + const handleGenerateVideo = useCallback(async (lengthInSeconds: number) => { try { // Fetch HeyGen API key from SecureStore const HEYGEN_API_KEY = await SecureStore.getItemAsync('api_key_heygen'); @@ -127,11 +131,12 @@ export function TopicVideoGenerator({ setIsLocalVideo(false); console.log('📹 Starting video generation for topic:', topicName); + console.log('📹 Video length:', lengthInSeconds, 'seconds'); console.log('📹 Using Avatar ID:', HEYGEN_AVATAR_ID); console.log('📹 Using Voice ID:', HEYGEN_VOICE_ID); - // Generate video script using Gemini - const script = await geminiService.generateVideoScript(topicName, topicName, subtopics); + // Generate video script using Gemini with specified length + const script = await geminiService.generateVideoScript(topicName, topicName, subtopics, 'default', lengthInSeconds); console.log('📹 Generated video script:', script); console.log('📹 Script length:', script.length, 'characters'); @@ -210,6 +215,15 @@ export function TopicVideoGenerator({ } }, [topicId, userId, topicName, subtopics]); + const handleShowLengthDialog = useCallback(() => { + setShowLengthDialog(true); + }, []); + + const handleConfirmGenerate = useCallback(() => { + setShowLengthDialog(false); + handleGenerateVideo(selectedLength); + }, [selectedLength, handleGenerateVideo]); + const handleDeleteVideo = useCallback(async () => { try { await deleteTopicVideo(topicId, userId); @@ -269,7 +283,7 @@ export function TopicVideoGenerator({ {videoStatus === 'idle' && !videoUrl && ( + + + + + + => { try { - console.log("Running database migrations..."); + console.log("[DB] Starting database initialization..."); + console.log("[DB] Database path:", expoDb.databaseName); + console.log("[DB] Running migrations..."); + + // Run migrations synchronously to ensure tables exist before any queries await migrate(db, migrations); - console.log("Database migrations completed successfully"); + + console.log("[DB] ✅ Database migrations completed successfully"); + + // Verify tables exist by running a simple query + try { + await db.query.users.findMany({ limit: 1 }); + console.log("[DB] ✅ Database schema verified - tables exist"); + } catch (verifyError: any) { + console.error("[DB] ❌ Schema verification failed:", verifyError?.message); + throw new Error(`Database schema verification failed: ${verifyError?.message}`); + } + return { db, migrationSuccess: true }; } catch (error: any) { - console.error("Database migration failed:", error); + console.error("[DB] ❌ Database initialization FAILED"); + console.error("[DB] Error:", error); + console.error("[DB] Error message:", error?.message); + console.error("[DB] Error stack:", error?.stack); + + // Check if error is due to tables already existing (safe to ignore) + const errorMsg = error?.message || ''; + const causeMsg = error?.cause?.message || ''; - // Check if error is due to tables already existing - if (error?.message?.includes("already exists") || error?.cause?.message?.includes("already exists")) { - console.log("Tables already exist, skipping migrations"); + if (errorMsg.includes("already exists") || causeMsg.includes("already exists")) { + console.log("[DB] ⚠️ Tables already exist, this is safe - continuing"); return { db, migrationSuccess: true }; } - // Still return db even if migrations fail (they might already be applied) - return { db, migrationSuccess: false }; + // For production, we MUST NOT continue with a broken database + // This will show the error screen instead of causing random query failures + console.error("[DB] 🛑 CRITICAL: Cannot continue without database initialization"); + throw error; // Re-throw to prevent app from running with broken DB } }; diff --git a/db/migrations/0000_striped_impossible_man.sql b/db/migrations/0000_striped_impossible_man.sql new file mode 100644 index 0000000..a802580 --- /dev/null +++ b/db/migrations/0000_striped_impossible_man.sql @@ -0,0 +1,190 @@ +CREATE TABLE `career_paths` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `role_name` text NOT NULL, + `role_description` text, + `total_estimated_hours` integer DEFAULT 0, + `categories` text DEFAULT '[]', + `progress` integer DEFAULT 0, + `status` text DEFAULT 'active', + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + `updated_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `career_paths_user_idx` ON `career_paths` (`user_id`);--> statement-breakpoint +CREATE TABLE `career_topics` ( + `id` text PRIMARY KEY NOT NULL, + `career_path_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `category` text NOT NULL, + `difficulty` text NOT NULL, + `estimated_hours` integer DEFAULT 0, + `order` integer NOT NULL, + `is_core` integer DEFAULT true, + `prerequisite_ids` text DEFAULT '[]', + `linked_topic_id` text, + `linked_roadmap_id` text, + `is_completed` integer DEFAULT false, + `completed_at` integer, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`career_path_id`) REFERENCES `career_paths`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`linked_topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`linked_roadmap_id`) REFERENCES `roadmaps`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `career_topics_career_path_idx` ON `career_topics` (`career_path_id`);--> statement-breakpoint +CREATE INDEX `career_topics_linked_topic_idx` ON `career_topics` (`linked_topic_id`);--> statement-breakpoint +CREATE INDEX `career_topics_linked_roadmap_idx` ON `career_topics` (`linked_roadmap_id`);--> statement-breakpoint +CREATE TABLE `questions` ( + `id` text PRIMARY KEY NOT NULL, + `quiz_id` text NOT NULL, + `subtopic_id` text, + `content` text NOT NULL, + `type` text DEFAULT 'multiple_choice', + `data` text NOT NULL, + FOREIGN KEY (`quiz_id`) REFERENCES `quizzes`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`subtopic_id`) REFERENCES `subtopics`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `quiz_attempts` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `quiz_id` text NOT NULL, + `score` integer, + `passed` integer, + `details` text, + `completed_at` integer DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`quiz_id`) REFERENCES `quizzes`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `quizzes` ( + `id` text PRIMARY KEY NOT NULL, + `title` text, + `topic_id` text, + `roadmap_id` text, + `type` text NOT NULL, + `difficulty` text, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`roadmap_id`) REFERENCES `roadmaps`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `roadmap_steps` ( + `id` text PRIMARY KEY NOT NULL, + `roadmap_id` text NOT NULL, + `order` integer NOT NULL, + `title` text NOT NULL, + `content` text, + `duration_minutes` integer, + `is_completed` integer DEFAULT false, + `last_completed_at` integer, + `topic_id` text, + FOREIGN KEY (`roadmap_id`) REFERENCES `roadmaps`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `roadmap_steps_roadmap_id_idx` ON `roadmap_steps` (`roadmap_id`);--> statement-breakpoint +CREATE TABLE `roadmaps` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `title` text NOT NULL, + `description` text, + `preferences` text DEFAULT '{}', + `status` text DEFAULT 'active', + `progress` integer DEFAULT 0, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + `updated_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `subtopics` ( + `id` text PRIMARY KEY NOT NULL, + `parent_topic_id` text NOT NULL, + `name` text NOT NULL, + `content_default` text, + `content_simplified` text, + `content_story` text, + `order` integer NOT NULL, + `metadata` text DEFAULT '{}', + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`parent_topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `subtopics_parent_idx` ON `subtopics` (`parent_topic_id`);--> statement-breakpoint +CREATE INDEX `subtopics_order_idx` ON `subtopics` (`parent_topic_id`,`order`);--> statement-breakpoint +CREATE UNIQUE INDEX `subtopics_parent_name_idx` ON `subtopics` (`parent_topic_id`,`name`);--> statement-breakpoint +CREATE UNIQUE INDEX `subtopics_parent_order_idx` ON `subtopics` (`parent_topic_id`,`order`);--> statement-breakpoint +CREATE TABLE `topic_videos` ( + `id` text PRIMARY KEY NOT NULL, + `topic_id` text NOT NULL, + `user_id` text NOT NULL, + `heygen_video_id` text NOT NULL, + `remote_url` text NOT NULL, + `local_file_path` text, + `status` text DEFAULT 'pending', + `file_size_bytes` integer, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + `downloaded_at` integer, + FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `topic_videos_topic_user_idx` ON `topic_videos` (`topic_id`,`user_id`);--> statement-breakpoint +CREATE INDEX `topic_videos_topic_idx` ON `topic_videos` (`topic_id`);--> statement-breakpoint +CREATE TABLE `topics` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `description` text, + `category` text NOT NULL, + `previous_topic_id` text, + `metadata` text DEFAULT '{}', + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`previous_topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `topics_name_unique` ON `topics` (`name`);--> statement-breakpoint +CREATE INDEX `topics_category_idx` ON `topics` (`category`);--> statement-breakpoint +CREATE INDEX `topics_previous_idx` ON `topics` (`previous_topic_id`);--> statement-breakpoint +CREATE TABLE `user_knowledge` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `topic_id` text NOT NULL, + `proficiency_level` integer DEFAULT 0, + `status` text DEFAULT 'locked', + `last_reviewed_at` integer, + `strength` integer DEFAULT 100, + `needs_regeneration` integer DEFAULT false, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_knowledge_user_topic_idx` ON `user_knowledge` (`user_id`,`topic_id`);--> statement-breakpoint +CREATE TABLE `user_subtopic_performance` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `subtopic_id` text NOT NULL, + `topic_id` text NOT NULL, + `correct_count` integer DEFAULT 0, + `incorrect_count` integer DEFAULT 0, + `total_attempts` integer DEFAULT 0, + `status` text DEFAULT 'neutral', + `last_attempt_at` integer, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`subtopic_id`) REFERENCES `subtopics`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_subtopic_performance_user_subtopic_idx` ON `user_subtopic_performance` (`user_id`,`subtopic_id`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `name` text DEFAULT 'Student', + `avatar_url` text, + `xp` integer DEFAULT 0, + `level` integer DEFAULT 1, + `current_streak` integer DEFAULT 0, + `is_onboarded` integer DEFAULT false, + `created_at` integer DEFAULT (CURRENT_TIMESTAMP) +); diff --git a/db/migrations/meta/0000_snapshot.json b/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..64a5349 --- /dev/null +++ b/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1403 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a3225e8b-e635-4c66-b5ab-4c9e81cb1b2e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "career_paths": { + "name": "career_paths", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_estimated_hours": { + "name": "total_estimated_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "career_paths_user_idx": { + "name": "career_paths_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "career_paths_user_id_users_id_fk": { + "name": "career_paths_user_id_users_id_fk", + "tableFrom": "career_paths", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "career_topics": { + "name": "career_topics", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "career_path_id": { + "name": "career_path_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimated_hours": { + "name": "estimated_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_core": { + "name": "is_core", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "prerequisite_ids": { + "name": "prerequisite_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "linked_topic_id": { + "name": "linked_topic_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_roadmap_id": { + "name": "linked_roadmap_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_completed": { + "name": "is_completed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "career_topics_career_path_idx": { + "name": "career_topics_career_path_idx", + "columns": [ + "career_path_id" + ], + "isUnique": false + }, + "career_topics_linked_topic_idx": { + "name": "career_topics_linked_topic_idx", + "columns": [ + "linked_topic_id" + ], + "isUnique": false + }, + "career_topics_linked_roadmap_idx": { + "name": "career_topics_linked_roadmap_idx", + "columns": [ + "linked_roadmap_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "career_topics_career_path_id_career_paths_id_fk": { + "name": "career_topics_career_path_id_career_paths_id_fk", + "tableFrom": "career_topics", + "tableTo": "career_paths", + "columnsFrom": [ + "career_path_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "career_topics_linked_topic_id_topics_id_fk": { + "name": "career_topics_linked_topic_id_topics_id_fk", + "tableFrom": "career_topics", + "tableTo": "topics", + "columnsFrom": [ + "linked_topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "career_topics_linked_roadmap_id_roadmaps_id_fk": { + "name": "career_topics_linked_roadmap_id_roadmaps_id_fk", + "tableFrom": "career_topics", + "tableTo": "roadmaps", + "columnsFrom": [ + "linked_roadmap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "questions": { + "name": "questions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subtopic_id": { + "name": "subtopic_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'multiple_choice'" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "questions_quiz_id_quizzes_id_fk": { + "name": "questions_quiz_id_quizzes_id_fk", + "tableFrom": "questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "questions_subtopic_id_subtopics_id_fk": { + "name": "questions_subtopic_id_subtopics_id_fk", + "tableFrom": "questions", + "tableTo": "subtopics", + "columnsFrom": [ + "subtopic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quiz_attempts": { + "name": "quiz_attempts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quizzes": { + "name": "quizzes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "roadmap_id": { + "name": "roadmap_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "quizzes_topic_id_topics_id_fk": { + "name": "quizzes_topic_id_topics_id_fk", + "tableFrom": "quizzes", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quizzes_roadmap_id_roadmaps_id_fk": { + "name": "quizzes_roadmap_id_roadmaps_id_fk", + "tableFrom": "quizzes", + "tableTo": "roadmaps", + "columnsFrom": [ + "roadmap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roadmap_steps": { + "name": "roadmap_steps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "roadmap_id": { + "name": "roadmap_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_completed": { + "name": "is_completed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "last_completed_at": { + "name": "last_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "roadmap_steps_roadmap_id_idx": { + "name": "roadmap_steps_roadmap_id_idx", + "columns": [ + "roadmap_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "roadmap_steps_roadmap_id_roadmaps_id_fk": { + "name": "roadmap_steps_roadmap_id_roadmaps_id_fk", + "tableFrom": "roadmap_steps", + "tableTo": "roadmaps", + "columnsFrom": [ + "roadmap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roadmaps": { + "name": "roadmaps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "roadmaps_user_id_users_id_fk": { + "name": "roadmaps_user_id_users_id_fk", + "tableFrom": "roadmaps", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subtopics": { + "name": "subtopics", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_topic_id": { + "name": "parent_topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_default": { + "name": "content_default", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_simplified": { + "name": "content_simplified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_story": { + "name": "content_story", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "subtopics_parent_idx": { + "name": "subtopics_parent_idx", + "columns": [ + "parent_topic_id" + ], + "isUnique": false + }, + "subtopics_order_idx": { + "name": "subtopics_order_idx", + "columns": [ + "parent_topic_id", + "order" + ], + "isUnique": false + }, + "subtopics_parent_name_idx": { + "name": "subtopics_parent_name_idx", + "columns": [ + "parent_topic_id", + "name" + ], + "isUnique": true + }, + "subtopics_parent_order_idx": { + "name": "subtopics_parent_order_idx", + "columns": [ + "parent_topic_id", + "order" + ], + "isUnique": true + } + }, + "foreignKeys": { + "subtopics_parent_topic_id_topics_id_fk": { + "name": "subtopics_parent_topic_id_topics_id_fk", + "tableFrom": "subtopics", + "tableTo": "topics", + "columnsFrom": [ + "parent_topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic_videos": { + "name": "topic_videos", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heygen_video_id": { + "name": "heygen_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_file_path": { + "name": "local_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_videos_topic_user_idx": { + "name": "topic_videos_topic_user_idx", + "columns": [ + "topic_id", + "user_id" + ], + "isUnique": true + }, + "topic_videos_topic_idx": { + "name": "topic_videos_topic_idx", + "columns": [ + "topic_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topic_videos_topic_id_topics_id_fk": { + "name": "topic_videos_topic_id_topics_id_fk", + "tableFrom": "topic_videos", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "topic_videos_user_id_users_id_fk": { + "name": "topic_videos_user_id_users_id_fk", + "tableFrom": "topic_videos", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topics": { + "name": "topics", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_topic_id": { + "name": "previous_topic_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "topics_name_unique": { + "name": "topics_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "topics_category_idx": { + "name": "topics_category_idx", + "columns": [ + "category" + ], + "isUnique": false + }, + "topics_previous_idx": { + "name": "topics_previous_idx", + "columns": [ + "previous_topic_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topics_previous_topic_id_topics_id_fk": { + "name": "topics_previous_topic_id_topics_id_fk", + "tableFrom": "topics", + "tableTo": "topics", + "columnsFrom": [ + "previous_topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_knowledge": { + "name": "user_knowledge", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'locked'" + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strength": { + "name": "strength", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "needs_regeneration": { + "name": "needs_regeneration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_knowledge_user_topic_idx": { + "name": "user_knowledge_user_topic_idx", + "columns": [ + "user_id", + "topic_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_knowledge_user_id_users_id_fk": { + "name": "user_knowledge_user_id_users_id_fk", + "tableFrom": "user_knowledge", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_knowledge_topic_id_topics_id_fk": { + "name": "user_knowledge_topic_id_topics_id_fk", + "tableFrom": "user_knowledge", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_subtopic_performance": { + "name": "user_subtopic_performance", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subtopic_id": { + "name": "subtopic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "correct_count": { + "name": "correct_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "incorrect_count": { + "name": "incorrect_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_attempts": { + "name": "total_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'neutral'" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "user_subtopic_performance_user_subtopic_idx": { + "name": "user_subtopic_performance_user_subtopic_idx", + "columns": [ + "user_id", + "subtopic_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_subtopic_performance_user_id_users_id_fk": { + "name": "user_subtopic_performance_user_id_users_id_fk", + "tableFrom": "user_subtopic_performance", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_subtopic_performance_subtopic_id_subtopics_id_fk": { + "name": "user_subtopic_performance_subtopic_id_subtopics_id_fk", + "tableFrom": "user_subtopic_performance", + "tableTo": "subtopics", + "columnsFrom": [ + "subtopic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_subtopic_performance_topic_id_topics_id_fk": { + "name": "user_subtopic_performance_topic_id_topics_id_fk", + "tableFrom": "user_subtopic_performance", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Student'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xp": { + "name": "xp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_onboarded": { + "name": "is_onboarded", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json new file mode 100644 index 0000000..404422c --- /dev/null +++ b/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1769347618032, + "tag": "0000_striped_impossible_man", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/db/migrations/migrations.js b/db/migrations/migrations.js new file mode 100644 index 0000000..e00dfd9 --- /dev/null +++ b/db/migrations/migrations.js @@ -0,0 +1,12 @@ +// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo + +import journal from './meta/_journal.json'; +import m0000 from './0000_striped_impossible_man.sql'; + +export default { + journal, + migrations: { + m0000 + } +}; + \ No newline at end of file diff --git a/db/provider.tsx b/db/provider.tsx index 1501d18..0ea1b0b 100644 --- a/db/provider.tsx +++ b/db/provider.tsx @@ -1,6 +1,7 @@ import type {ExpoSQLiteDatabase} from "drizzle-orm/expo-sqlite"; import type {SQLJsDatabase} from "drizzle-orm/sql-js"; import React, {type PropsWithChildren, useContext, useEffect, useState} from "react"; +import {View, ActivityIndicator, Text} from "react-native"; import {initialize} from "./drizzle"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,14 +15,66 @@ export const useDatabase = () => useContext(DatabaseContext); export function DatabaseProvider({children}: PropsWithChildren) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const [db, setDb] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [initError, setInitError] = useState(null); useEffect(() => { - if (db) return - initialize().then((newDb) => { - setDb(newDb); - }) + if (isInitialized) return; + + console.log("[DatabaseProvider] Initializing database..."); + initialize() + .then((result) => { + console.log("[DatabaseProvider] Database initialized successfully"); + console.log("[DatabaseProvider] Migration status:", result.migrationSuccess ? "✅ SUCCESS" : "❌ FAILED"); + + if (!result.migrationSuccess) { + setInitError("Database migrations failed. Please reinstall the app."); + setIsInitialized(true); + return; + } + + setDb(result.db); + setIsInitialized(true); + }) + .catch((error) => { + console.error("[DatabaseProvider] ❌ CRITICAL: Database initialization failed:", error); + setInitError(error?.message || "Failed to initialize database"); + setIsInitialized(true); + }); + }, [isInitialized]); - }, []); + // Show error screen if database initialization failed + if (initError) { + return ( + + ⚠️ Database Error + + Failed to initialize the database + + + {initError} + + + Please uninstall and reinstall the app. + + + If the problem persists, contact support. + + + ); + } + + // Show loading screen while database is initializing + if (!isInitialized || !db) { + return ( + + + + Initializing database... + + + ); + } return ( diff --git a/db/schema.ts b/db/schema.ts index a575c4b..f19021f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -106,6 +106,8 @@ export const subtopics = sqliteTable( (t) => [ index("subtopics_parent_idx").on(t.parentTopicId), index("subtopics_order_idx").on(t.parentTopicId, t.order), + uniqueIndex("subtopics_parent_name_idx").on(t.parentTopicId, t.name), + uniqueIndex("subtopics_parent_order_idx").on(t.parentTopicId, t.order), ] ); diff --git a/deleting_migration_deleting_android.sh b/deleting_migration_deleting_android.sh new file mode 100644 index 0000000..a0a060e --- /dev/null +++ b/deleting_migration_deleting_android.sh @@ -0,0 +1,31 @@ +rm public/database.sqlite +clear +echo "file removed" +sleep 2 +touch public/database.sqlite +clear +echo "file created" +sleep 2 +rm -rf db/migrations +clear +echo "migrations folder removed" +sleep 2 +bun db:generate +clear +echo "db generated" +sleep 2 +bun db:migrate +clear +echo "db migrated" +sleep 2 +rm -rf android +clear +echo "android folder removed" +bun expo prebuild --platform android +clear +echo "android folder created" +mkdir -p android/app/src/main/assets +cp face_landmarker.task android/app/src/main/assets/ +clear +echo "file copied" +bun run android \ No newline at end of file diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..29f6a2a --- /dev/null +++ b/eas.json @@ -0,0 +1,47 @@ +{ + "cli": { + "version": ">= 13.2.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "gradleCommand": ":app:assembleDebug" + } + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "preview-release": { + "distribution": "internal", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleRelease" + } + }, + "production": { + "android": { + "buildType": "apk" + }, + "env": { + "NODE_ENV": "production" + } + }, + "production-aab": { + "android": { + "buildType": "app-bundle" + }, + "env": { + "NODE_ENV": "production" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/lib/emotion/EmotionDetector.ts b/lib/emotion/EmotionDetector.ts index 6a25b35..5997f9c 100644 --- a/lib/emotion/EmotionDetector.ts +++ b/lib/emotion/EmotionDetector.ts @@ -395,7 +395,7 @@ export class EmotionDetector { emotion: "looking_away", confidence: Math.max(0.6, confidence), features, - }; + }; } // 2. Drowsy - very low EAR (eyes nearly closed) diff --git a/lib/emotion/detectEmotion.ts b/lib/emotion/detectEmotion.ts index 24c7917..5b8db2c 100644 --- a/lib/emotion/detectEmotion.ts +++ b/lib/emotion/detectEmotion.ts @@ -1,5 +1,17 @@ -import { FaceLandmarks } from "@/modules/face-landmarks/src"; import { EmotionDetector, normalizeLandmarks, type EmotionResult } from "./EmotionDetector"; +import { Platform } from "react-native"; + +// Conditionally import native face landmarks module +let FaceLandmarks: any = null; +try { + if (Platform.OS !== "web") { + const faceLandmarksModule = require("@/modules/face-landmarks/src"); + FaceLandmarks = faceLandmarksModule.FaceLandmarks; + } +} catch (error) { + console.warn("FaceLandmarks native module not available:", error); + FaceLandmarks = null; +} const detector = new EmotionDetector(); @@ -10,6 +22,27 @@ const detector = new EmotionDetector(); * @returns EmotionResult with emotion label, confidence, and all computed features */ export async function detectEmotionFromImageUri(uri: string): Promise { + // Check if module is available + if (!FaceLandmarks) { + return { + emotion: "no_face", + confidence: 0.0, + features: { + ear: 0, + mar: 0, + brow_height: 0, + brow_asymmetry: 0, + tilt_angle: 0, + rotation_ratio: 0, + symmetry_ratio: 0, + energy_concentration: 0, + jaw_width: 0, + brow_variance: 0, + looking_at_camera: false, + }, + }; + } + try { // Call native module to get face landmarks const { width, height, landmarks } = await FaceLandmarks.detectFromImageAsync(uri); diff --git a/lib/gemini.ts b/lib/gemini.ts index 6a52c75..9a50a09 100644 --- a/lib/gemini.ts +++ b/lib/gemini.ts @@ -1115,14 +1115,16 @@ ${canonicalTitles.map((title, idx) => ` ${idx + 1}. "${title}"`).join('\n') /** * Generate a video script for HeyGen based on topic subtopics - * Creates a ~170 second (2 minutes 50 seconds) educational video script + * Creates an educational video script of specified length * @param tone - The tone/style of the script (default, simplified, or story) + * @param lengthInSeconds - The desired length of the video in seconds (default: 120) */ async generateVideoScript( topicName: string, context: string, subtopics: TopicSubtopic[], - tone: 'default' | 'simplified' | 'story' = 'default' + tone: 'default' | 'simplified' | 'story' = 'default', + lengthInSeconds: number = 120 ): Promise { let toneGuidance = ''; @@ -1145,18 +1147,26 @@ Be clear and informative while maintaining accuracy. Include practical examples and key insights.`; } + // Calculate word count based on speaking rate (~140 words per minute average) + const targetWordCount = Math.round((lengthInSeconds / 60) * 140); + const hookDuration = Math.round(lengthInSeconds * 0.12); // 12% for hook + const introDuration = Math.round(lengthInSeconds * 0.18); // 18% for intro + const mainDuration = Math.round(lengthInSeconds * 0.53); // 53% for main content + const practicalDuration = Math.round(lengthInSeconds * 0.12); // 12% for practical + const conclusionDuration = Math.round(lengthInSeconds * 0.05); // 5% for conclusion + const prompt = ` -Create a comprehensive, engaging script for a 170 second (approximately 2 minutes 50 seconds) educational video about "${topicName}" in the context of learning "${context}". +Create a comprehensive, engaging script for a ${lengthInSeconds} second (${Math.floor(lengthInSeconds / 60)} minutes ${lengthInSeconds % 60} seconds) educational video about "${topicName}" in the context of learning "${context}". ${toneGuidance} -The script should be approximately 370-425 words (speaking pace: ~130-150 words per minute for 170 seconds). +The script should be approximately ${targetWordCount} words (speaking pace: ~140 words per minute for ${lengthInSeconds} seconds). Structure the script as follows: -1. Opening Hook (20 seconds): Grab attention with an interesting fact or question about ${topicName} -2. Introduction (30 seconds): Explain what ${topicName} is and why it matters for ${context} -3. Main Content (90 seconds): Cover the key subtopics below, with clear explanations and examples -4. Practical Application (20 seconds): Show how to apply this knowledge in real scenarios -5. Conclusion & Call-to-Action (10 seconds): Summarize key takeaways and encourage practice +1. Opening Hook (${hookDuration} seconds): Grab attention with an interesting fact or question about ${topicName} +2. Introduction (${introDuration} seconds): Explain what ${topicName} is and why it matters for ${context} +3. Main Content (${mainDuration} seconds): Cover the key subtopics below, with clear explanations and examples +4. Practical Application (${practicalDuration} seconds): Show how to apply this knowledge in real scenarios +5. Conclusion & Call-to-Action (${conclusionDuration} seconds): Summarize key takeaways and encourage practice Cover these subtopics in the main content section: ${JSON.stringify(subtopics.map(s => ({ title: s.title, explanation: s.explanationDefault?.substring(0, 150) + '...' })), null, 2)} @@ -1164,11 +1174,13 @@ ${JSON.stringify(subtopics.map(s => ({ title: s.title, explanation: s.explanatio Requirements: - Write in a conversational, engaging style as if speaking directly to the learner - Use transitions between sections to maintain flow -- Include 1-2 concrete examples throughout +- Include ${lengthInSeconds >= 180 ? '2-3' : lengthInSeconds >= 120 ? '1-2' : '1'} concrete example${lengthInSeconds >= 120 ? 's' : ''} throughout - Keep sentences concise and easy to understand when spoken aloud - Avoid overly complex vocabulary - Include natural pauses (indicated by periods and paragraph breaks) -- Focus on the most important points given the 170 second time constraint +- Focus on the most important points given the ${lengthInSeconds} second time constraint +${lengthInSeconds <= 90 ? '- Keep explanations brief and focus only on essential concepts' : ''} +${lengthInSeconds >= 240 ? '- Provide more detailed explanations with additional examples and context' : ''} Output ONLY the script text ready for voice narration. Do NOT include: - JSON formatting @@ -1177,6 +1189,9 @@ Output ONLY the script text ready for voice narration. Do NOT include: - Bullets or numbered lists - Stage directions or meta-commentary +CRITICAL: The script MUST be approximately ${targetWordCount} words to achieve ${lengthInSeconds} seconds of speaking time. +Count your words carefully. Too short = video ends abruptly. Too long = video gets cut off. + The script should flow naturally as continuous narration that a presenter would speak. `; @@ -1197,8 +1212,9 @@ The script should flow naturally as continuous narration that a presenter would .replace(/^\s*\d+\.\s+/gm, '') // Remove numbered lists .trim(); - if (!cleanedScript || cleanedScript.length < 300) { - throw new Error('Generated script is too short for a 170 second video'); + const minLength = Math.round(lengthInSeconds * 2); // Roughly 2 chars per second minimum + if (!cleanedScript || cleanedScript.length < minLength) { + throw new Error(`Generated script is too short for a ${lengthInSeconds} second video (minimum ${minLength} characters)`); } return cleanedScript; diff --git a/package.json b/package.json index 391c4a7..7ae020c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "android": "bun expo run:android", "ios": "expo run:ios", "build:web": "expo export --platform web", + "prebuild:copy-assets": "mkdir -p android/app/src/main/assets && cp face_landmarker.task android/app/src/main/assets/", + "build:android": "bun run prebuild:copy-assets && eas build --platform android --profile production", + "build:android-local": "bun run prebuild:copy-assets && cd android && ./gradlew assembleRelease", + "build:apk": "bun run prebuild:copy-assets && eas build --platform android --profile production", "format": "biome check --no-errors-on-unmatched --apply .", "db:generate": "bun drizzle-kit generate", "db:migrate": "bun db/migrate.ts", @@ -64,6 +68,7 @@ "expo-status-bar": "~3.0.8", "expo-system-ui": "~6.0.7", "expo-web-browser": "~15.0.8", + "face-landmarks": "file:./modules/face-landmarks", "gh-pages": "^6.3.0", "groq-sdk": "^0.37.0", "lucide-react-native": "^0.514.0", diff --git a/plugins/withFaceLandmarkerAsset.js b/plugins/withFaceLandmarkerAsset.js new file mode 100644 index 0000000..982ee20 --- /dev/null +++ b/plugins/withFaceLandmarkerAsset.js @@ -0,0 +1,43 @@ +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * Expo Config Plugin to copy face_landmarker.task to Android assets + * This runs after prebuild generates the android/ folder + */ +const withFaceLandmarkerAsset = (config) => { + return withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const sourceFile = path.join(projectRoot, 'face_landmarker.task'); + const targetDir = path.join( + projectRoot, + 'android', + 'app', + 'src', + 'main', + 'assets' + ); + const targetFile = path.join(targetDir, 'face_landmarker.task'); + + // Create assets directory if it doesn't exist + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + // Copy model file if source exists + if (fs.existsSync(sourceFile)) { + fs.copyFileSync(sourceFile, targetFile); + console.log('✅ Copied face_landmarker.task to Android assets'); + } else { + console.warn('⚠️ face_landmarker.task not found in project root'); + } + + return config; + }, + ]); +}; + +module.exports = withFaceLandmarkerAsset; diff --git a/public/database.sqlite b/public/database.sqlite index 6b9dddf..c292f62 100644 Binary files a/public/database.sqlite and b/public/database.sqlite differ diff --git a/resize_image.py b/resize_image.py deleted file mode 100644 index b055a7e..0000000 --- a/resize_image.py +++ /dev/null @@ -1,15 +0,0 @@ -from PIL import Image -import numpy as np - -# Load and resize exactly like training -img = Image.open('assets/images/4.jpg').convert('RGB') -print(f'Original size: {img.size}') - -img_resized = img.resize((224, 224), Image.BILINEAR) -img_resized.save('assets/images/4_resized_224.jpg', quality=100, subsampling=0) - -# Verify pixels -pixels = np.array(img_resized) -print('First 3 pixels:', pixels[0, 0:3]) -print('Mean:', pixels.mean()) -print('Saved: assets/images/1_resized_224.jpg') diff --git a/server/agents/VideoGen.ts b/server/agents/VideoGen.ts index 33dc4a9..fba2ab8 100644 --- a/server/agents/VideoGen.ts +++ b/server/agents/VideoGen.ts @@ -22,6 +22,7 @@ interface VideoAgentState { apiKey: string; avatarId: string; voiceId: string; + lengthInSeconds: number; } // ------------------------------ @@ -57,14 +58,16 @@ async function withRetry( // Script Generation Node // ------------------------------ async function generateScriptNode(state: VideoAgentState): Promise { - console.log("🎬 Agent Node: Generating video script..."); + console.log(`🎬 Agent Node: Generating video script (${state.lengthInSeconds}s)...`); const script = await withRetry( async () => { return await geminiService.generateVideoScript( state.topicName, state.context, - state.subtopics + state.subtopics, + 'default', + state.lengthInSeconds ); }, 3, @@ -147,9 +150,10 @@ export async function generateTopicVideo( subtopics: TopicSubtopic[], apiKey: string, avatarId: string, - voiceId: string + voiceId: string, + lengthInSeconds: number = 120 ): Promise { - console.log("🎬 Video Agent: Starting video generation for:", topicName); + console.log(`🎬 Video Agent: Starting video generation for: ${topicName} (${lengthInSeconds}s)`); // Validate inputs if (!apiKey || !avatarId || !voiceId) { @@ -166,6 +170,7 @@ export async function generateTopicVideo( apiKey, avatarId, voiceId, + lengthInSeconds, }; // Step 1: Generate script diff --git a/server/queries/topics.ts b/server/queries/topics.ts index feda771..9e2788d 100644 --- a/server/queries/topics.ts +++ b/server/queries/topics.ts @@ -1,4 +1,4 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, max, sql } from 'drizzle-orm'; import { db } from '@/db/drizzle'; import { topics, roadmapSteps, roadmaps, subtopics, userSubtopicPerformance, userKnowledge } from '@/db/schema'; import { createId, isCuid } from '@paralleldrive/cuid2'; @@ -95,24 +95,35 @@ export async function createSubtopics( const source = isWebSearchGenerated ? 'websearch' : 'original'; return await db.transaction(async (tx) => { - // Check if subtopics already exist for this topic with this source + // Check if subtopics already exist for this topic const existingSubtopics = await tx - .select({ id: subtopics.id, metadata: subtopics.metadata }) + .select({ id: subtopics.id, name: subtopics.name, metadata: subtopics.metadata }) .from(subtopics) .where(eq(subtopics.parentTopicId, parentTopicId)); - // Filter existing subtopics by source - const existingWithSameSource = existingSubtopics.filter(st => { - const meta = (st.metadata as Record) || {}; - return meta.source === source; + // Create a set of existing subtopic names (normalized for comparison) + const existingNames = new Set( + existingSubtopics.map(st => st.name.toLowerCase().trim()) + ); + const seenNames = new Set(existingNames); + + // Filter out subtopics that already exist by name + const newSubtopicsToCreate = explanation.subtopics.filter(st => { + const normalizedName = st.title.toLowerCase().trim(); + if (seenNames.has(normalizedName)) { + console.log(`⏭️ [DB] Subtopic "${st.title}" already exists or is duplicated in this batch, skipping`); + return false; + } + seenNames.add(normalizedName); + return true; }); - if (existingWithSameSource.length > 0) { - console.log(`⏭️ [DB] Subtopics with source='${source}' already exist (${existingWithSameSource.length}), skipping creation`); - return; // Don't create duplicates + if (newSubtopicsToCreate.length === 0) { + console.log(`⏭️ [DB] All ${explanation.subtopics.length} subtopics already exist, skipping creation`); + return; // All subtopics already exist } - console.log(`✅ [DB] No existing subtopics with source='${source}', proceeding with creation`); + console.log(`✅ [DB] Creating ${newSubtopicsToCreate.length} new subtopics (${explanation.subtopics.length - newSubtopicsToCreate.length} already exist)`); // Update parent topic with metadata const parentTopic = await tx @@ -148,38 +159,80 @@ export async function createSubtopics( console.log(`🗄️ [DB] Starting subtopic insertion loop...`); - for (let i = 0; i < explanation.subtopics.length; i++) { - const subtopic = explanation.subtopics[i]; + // Helper function to insert subtopic with atomic order assignment and retry on conflict + const insertSubtopicWithRetry = async (subtopic: typeof newSubtopicsToCreate[0], maxRetries = 5): Promise => { const subtopicId = createId(); - console.log(`🗄️ [DB] Inserting subtopic ${i + 1}/${explanation.subtopics.length}: "${subtopic.title}"`); + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // Query max order atomically within each attempt to avoid race conditions + const maxOrderResult = await tx + .select({ maxOrder: max(subtopics.order) }) + .from(subtopics) + .where(eq(subtopics.parentTopicId, parentTopicId)); + + const nextOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1; + + // Attempt insert with the calculated order + await tx.insert(subtopics).values({ + id: subtopicId, + parentTopicId: parentTopicId, + name: subtopic.title, + contentDefault: subtopic.explanationDefault, + contentSimplified: subtopic.explanationSimplified, + contentStory: subtopic.explanationStory, + order: nextOrder, + metadata: JSON.stringify({ + source: source, + example: subtopic.example, + exampleExplanation: subtopic.exampleExplanation, + exampleSimplified: subtopic.exampleSimplified, + exampleStory: subtopic.exampleStory, + keyPoints: subtopic.keyPoints + }) + }); + + console.log(`✅ [DB] Created subtopic with 3 content types: ${subtopic.title} (order: ${nextOrder}, source: ${source})`); + return; // Success, exit retry loop + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Check if error is due to unique constraint violation on (parentTopicId, order) + const isOrderConflict = errorMessage.includes('UNIQUE constraint failed') && + errorMessage.includes('parentTopicId') && + errorMessage.includes('order'); + + if (isOrderConflict && attempt < maxRetries - 1) { + // Retry after a brief delay with exponential backoff + const delayMs = Math.pow(2, attempt) * 10; // 10ms, 20ms, 40ms, 80ms, 160ms + console.warn(`⚠️ [DB] Order conflict for "${subtopic.title}", retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + continue; + } + + // Either not an order conflict or max retries reached + console.error(`❌ [DB] Failed to create subtopic ${subtopic.title}:`, error); + throw error; // Propagate error to outer catch + } + } + + throw new Error(`Failed to insert subtopic "${subtopic.title}" after ${maxRetries} attempts`); + }; + + // Insert subtopics sequentially to maintain order consistency + for (let i = 0; i < newSubtopicsToCreate.length; i++) { + const subtopic = newSubtopicsToCreate[i]; + + console.log(`🗄️ [DB] Inserting subtopic ${i + 1}/${newSubtopicsToCreate.length}: "${subtopic.title}"`); console.log(`🗄️ [DB] Content lengths - Default: ${subtopic.explanationDefault?.length || 0}, Simplified: ${subtopic.explanationSimplified?.length || 0}, Story: ${subtopic.explanationStory?.length || 0}`); try { - await tx.insert(subtopics).values({ - id: subtopicId, - parentTopicId: parentTopicId, - name: subtopic.title, - contentDefault: subtopic.explanationDefault, - contentSimplified: subtopic.explanationSimplified, - contentStory: subtopic.explanationStory, - order: i + 1, - metadata: JSON.stringify({ - source: source, // Track whether this is 'original' or 'websearch' content - example: subtopic.example, - exampleExplanation: subtopic.exampleExplanation, - exampleSimplified: subtopic.exampleSimplified, - exampleStory: subtopic.exampleStory, - keyPoints: subtopic.keyPoints - }) - }); - - console.log(`✅ [DB] Created subtopic with 3 content types: ${subtopic.title} (source: ${source})`); + await insertSubtopicWithRetry(subtopic); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error(`Failed to create subtopic ${subtopic.title}:`, error); errors.push({ - subtopicId, + subtopicId: subtopic.title, // Use title as identifier since subtopicId is internal to retry function title: subtopic.title, error: errorMessage });