diff --git a/.env.example b/.env.example index 0150b71..1ddc80f 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,8 @@ SMTP_PASSWORD=your-app-password SMTP_DOMAIN=soundscape.com # AI Configuration -GEMINI_API_KEY=your-gemini-api-key-here +OPENAI_API_KEY=your-openai-api-key-here +# OPENAI_MODEL=gpt-3.5-turbo # Redis Configuration (for caching and rate limiting) REDIS_URL=redis://localhost:6379/0 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d99f220..8889d3f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -41,7 +41,7 @@ En la sección **Environment Variables**, agrega: #### Variables Requeridas (las tienes que configurar) - `RAILS_MASTER_KEY` → Copia el contenido de `config/master.key` -- `GEMINI_API_KEY` → Tu clave de API de Google Gemini +- `OPENAI_API_KEY` → Tu clave API de OpenAI - `SMTP_USERNAME` → Tu email de Gmail - `SMTP_PASSWORD` → Tu contraseña de aplicación de Gmail - `CORS_ALLOWED_ORIGINS` → URL de tu frontend en Vercel (ej: `https://soundscape-frontend.vercel.app`) @@ -81,20 +81,20 @@ https://soundscape-api.onrender.com ``` ### Endpoints principales: -- Health Check: `GET /up` -- API Base: `/api/v1/` +- Chequeo de salud: `GET /up` +- API base: `/api/v1/` - Autenticación: `/api/v1/auth/login` - Playlists: `/api/v1/playlists` -- AI Search: `/api/v1/ai_search` +- Búsqueda con IA: `/api/v1/ai_search` -## 🔧 Troubleshooting +## 🔧 Solución de Problemas -### Error: "Web service failed to start" +### Error: "El servicio web no pudo iniciar" 1. Revisa los logs en Render Dashboard 2. Verifica que `RAILS_MASTER_KEY` sea correcto 3. Asegúrate de que todas las variables de entorno estén configuradas -### Error: Database connection +### Error: Conexión a la base de datos 1. Verifica que `DATABASE_URL` esté conectada correctamente 2. La base de datos debe estar en el mismo proyecto @@ -102,7 +102,7 @@ https://soundscape-api.onrender.com 1. Verifica que `CORS_ALLOWED_ORIGINS` contenga la URL exacta de tu frontend 2. No agregues "/" al final de las URLs -### Error: Email sending +### Error: Envío de correo 1. Verifica `SMTP_USERNAME` y `SMTP_PASSWORD` 2. Asegúrate de usar una contraseña de aplicación, no tu contraseña de Gmail normal @@ -118,4 +118,4 @@ Una vez que tu API esté funcionando: Si tienes problemas: 1. Revisa los logs en Render Dashboard 2. Verifica que todas las variables de entorno estén configuradas -3. Prueba los endpoints manualmente con curl o Postman \ No newline at end of file +3. Prueba los endpoints manualmente con curl o Postman diff --git a/Gemfile b/Gemfile index 650adf3..5fc7b8d 100644 --- a/Gemfile +++ b/Gemfile @@ -41,11 +41,8 @@ gem "jwt" # Authorization with CanCanCan gem "cancancan" -# AI Integration with Google Gemini -gem "gemini-ai" - -# HTTP client for API requests -gem "faraday" +# OpenAI client +gem "ruby-openai", require: "openai" # Redis for caching and rate limiting gem "redis" diff --git a/Gemfile.lock b/Gemfile.lock index b5baee7..99a6afd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,9 +109,7 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo - ethon (0.16.0) ffi (>= 1.15.0) - event_stream_parser (1.0.0) factory_bot (6.5.4) activesupport (>= 6.1.0) factory_bot_rails (6.5.0) @@ -125,9 +123,6 @@ GEM logger faraday-net_http (3.4.1) net-http (>= 0.5.0) - faraday-typhoeus (1.1.0) - faraday (~> 2.0) - typhoeus (~> 1.4) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -139,26 +134,8 @@ GEM fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) - gemini-ai (4.3.0) - event_stream_parser (~> 1.0) - faraday (~> 2.13, >= 2.13.2) - faraday-typhoeus (~> 1.1) - googleauth (~> 1.8) - typhoeus (~> 1.4, >= 1.4.1) globalid (1.2.1) activesupport (>= 6.1) - google-cloud-env (2.3.1) - base64 (~> 0.2) - faraday (>= 1.0, < 3.a) - google-logging-utils (0.2.0) - googleauth (1.14.0) - faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.2) - google-logging-utils (~> 0.1) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.8.1) @@ -229,7 +206,6 @@ GEM racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-musl) racc (~> 1.4) - os (1.1.4) ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) @@ -355,11 +331,6 @@ GEM securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) - signet (0.20.0) - addressable (~> 2.8) - faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) solid_cable (3.0.11) actioncable (>= 7.2) activejob (>= 7.2) @@ -391,8 +362,6 @@ GEM thruster (0.1.15-x86_64-darwin) thruster (0.1.15-x86_64-linux) timeout (0.4.3) - typhoeus (1.4.1) - ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) @@ -428,8 +397,6 @@ DEPENDENCIES dotenv-rails factory_bot_rails faker - faraday - gemini-ai jwt kamal pg (~> 1.1) @@ -439,6 +406,7 @@ DEPENDENCIES redis rspec-rails rubocop-rails-omakase + ruby-openai shoulda-matchers (~> 6.5) solid_cable solid_cache diff --git a/README.md b/README.md index 91bef75..9a290d7 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ SoundScape es una API REST construida con Ruby on Rails que permite a los usuari - **Autenticación JWT** con confirmación de email obligatoria - **Gestión de Playlists** y canciones con autorización basada en roles -- **Búsqueda Inteligente con IA** usando Google Gemini para sugerencias de canciones -- **Sistema de Analytics** para tracking de búsquedas y tendencias -- **Cache Inteligente** y rate limiting para optimización de rendimiento +- **Búsqueda Potenciada con IA** usando OpenAI ChatGPT para sugerencias de canciones +- **Sistema de análisis** para seguimiento de búsquedas y tendencias +- **Cache Inteligente** y limitación de tasa para optimización de rendimiento - **Exportación** a Spotify/YouTube Music (próximamente) ## 🛠 Tecnologías @@ -18,7 +18,7 @@ SoundScape es una API REST construida con Ruby on Rails que permite a los usuari - **PostgreSQL** como base de datos - **JWT** para autenticación - **CanCanCan** para autorización -- **Google Gemini AI** para búsqueda inteligente +- **OpenAI ChatGPT** para búsquedas con IA - **RSpec** para testing - **RuboCop** para formateo de código @@ -26,8 +26,8 @@ SoundScape es una API REST construida con Ruby on Rails que permite a los usuari - Ruby 3.2.2+ - PostgreSQL 12+ -- Redis (para cache y rate limiting) -- Gemini API Key de Google +- Redis (para cache y limitación de tasa) +- Clave API de OpenAI ## ⚙️ Configuración @@ -51,28 +51,28 @@ rails db:migrate Crear archivo `.env` basado en `.env.example`: ```bash -# Database Configuration +# Configuración de Base de Datos DATABASE_URL=postgresql://username:password@localhost/soundscape_development -# Email Configuration +# Configuración de Correo SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USERNAME=your-email@gmail.com SMTP_PASSWORD=your-app-password SMTP_DOMAIN=soundscape.com -# AI Configuration -GEMINI_API_KEY=your-gemini-api-key-here +# Configuración de IA +OPENAI_API_KEY=your-openai-api-key-here -# Redis Configuration (for caching and rate limiting) +# Configuración de Redis (para cache y limitación de tasa) REDIS_URL=redis://localhost:6379/0 ``` -### 3. Obtener Gemini API Key +### 3. Obtener la API Key de OpenAI -1. Visita [Google AI Studio](https://makersuite.google.com/app/apikey) -2. Crea una nueva API key -3. Agrega la key a tu archivo `.env` +1. Visita [OpenAI Platform](https://platform.openai.com/account/api-keys) +2. Crea una nueva clave API +3. Agrega la clave a tu archivo `.env` ## 🚀 Uso @@ -177,32 +177,32 @@ Actualmente tenemos **107+ tests pasando** con cobertura completa de: - Modelos y validaciones - Controladores y autenticación - Servicios de IA y cache -- Analytics y rate limiting +- Análisis y limitación de tasa ## 📊 Funcionalidades de IA ### Búsqueda Inteligente -El sistema utiliza **Google Gemini 1.5 Flash** para generar sugerencias inteligentes de canciones: +El sistema usa **OpenAI ChatGPT** para generar sugerencias de canciones: - **Cache**: Resultados cacheados por 1 hora para mejor rendimiento -- **Rate Limiting**: 60 búsquedas por hora por usuario +- **Limitación de tasa**: 60 búsquedas por hora por usuario - **Validación**: Queries entre 2-100 caracteres -- **Fallback**: Respuesta graceful cuando la IA no está disponible +- **Respuesta alternativa**: Respuesta de emergencia cuando la IA no está disponible -### Analytics y Tendencias +### Análisis y Tendencias -- **Tracking de búsquedas**: Registra consultas, timestamps y resultados -- **Búsquedas trending**: Top consultas en períodos configurables +- **Seguimiento de búsquedas**: Registra consultas, timestamps y resultados +- **Búsquedas en tendencia**: Top consultas en períodos configurables - **Historial personal**: Búsquedas paginadas por usuario -- **Datos anónimos**: IP addresses para analytics sin identificación personal +- **Datos anónimos**: Direcciones IP para análisis sin identificación personal ## 🔒 Seguridad -- **JWT Authentication** con tokens seguros -- **Email confirmation** obligatoria antes del acceso -- **Rate limiting** por usuario y endpoint -- **Authorization** basada en roles con CanCanCan +- **Autenticación JWT** con tokens seguros +- **Confirmación de email** obligatoria antes del acceso +- **Limitación de tasa** por usuario y endpoint +- **Autorización** basada en roles con CanCanCan - **Validación** exhaustiva de inputs - **Logs seguros** sin exposición de datos sensibles @@ -228,7 +228,7 @@ docker-compose up -d - [ ] Sistema de recomendaciones personalizado - [ ] Compartir playlists entre usuarios - [ ] API de exportación masiva -- [ ] Dashboard de analytics +- [ ] Dashboard de análisis - [ ] Mobile SDK ## 🤝 Contribución diff --git a/app/services/ai_search_service.rb b/app/services/ai_search_service.rb index eeae0c4..83d63d8 100644 --- a/app/services/ai_search_service.rb +++ b/app/services/ai_search_service.rb @@ -12,8 +12,8 @@ class AiSearchService def initialize(attributes = {}) super - @api_key = ENV["GEMINI_API_KEY"] - raise StandardError, "GEMINI_API_KEY not configured" if @api_key.blank? + @api_key = ENV["OPENAI_API_KEY"] + raise StandardError, "OPENAI_API_KEY not configured" if @api_key.blank? end def search_songs @@ -98,31 +98,20 @@ def fetch_from_cache def generate_ai_suggestions prompt = build_search_prompt - - conn = Faraday.new("https://generativelanguage.googleapis.com") - - response = conn.post("/v1beta/models/gemini-1.5-flash:generateContent") do |req| - req.params["key"] = @api_key - req.headers["Content-Type"] = "application/json" - req.body = { - contents: [{ - parts: [{ text: prompt }] - }] - }.to_json - end - - if response.success? - result = JSON.parse(response.body) - result.dig("candidates", 0, "content", "parts", 0, "text") || "" - else - Rails.logger.error "Gemini API Error: #{response.status} - #{response.body}" - raise StandardError, "Gemini API request failed: #{response.status}" - end - rescue JSON::ParserError => e - Rails.logger.error "JSON Parse Error: #{e.message}" - raise StandardError, "Failed to parse Gemini response" + + client = OpenAI::Client.new(access_token: @api_key) + response = client.chat( + parameters: { + model: ENV.fetch("OPENAI_MODEL", "gpt-3.5-turbo"), + messages: [ + { role: "user", content: prompt } + ] + } + ) + + response.dig("choices", 0, "message", "content") || "" rescue StandardError => e - Rails.logger.error "Gemini API Error: #{e.message}" + Rails.logger.error "OpenAI API Error: #{e.message}" raise e end diff --git a/render.yaml b/render.yaml index 8811ea3..aad4545 100644 --- a/render.yaml +++ b/render.yaml @@ -40,8 +40,8 @@ services: # CORS Configuration - key: CORS_ALLOWED_ORIGINS value: https://soundscape-frontend-qzwqagdi9-jean612s-projects.vercel.app - # Gemini AI API Key - - key: GEMINI_API_KEY + # OpenAI API Key + - key: OPENAI_API_KEY sync: false buildCommand: bundle install startCommand: ./bin/render-start.sh diff --git a/spec/controllers/api/v1/ai_search_controller_spec.rb b/spec/controllers/api/v1/ai_search_controller_spec.rb index 8513c5b..ddd960c 100644 --- a/spec/controllers/api/v1/ai_search_controller_spec.rb +++ b/spec/controllers/api/v1/ai_search_controller_spec.rb @@ -7,6 +7,7 @@ before do request.headers.merge!(headers) + ENV['OPENAI_API_KEY'] = 'test-key' end describe 'POST #search' do @@ -33,23 +34,29 @@ }) end - it 'returns successful response' do + it 'returns http ok status' do post :search, params: valid_params - + expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response['success']).to be true - expect(json_response['data']['songs']).to be_an(Array) - expect(json_response['data']['songs'].first['title']).to eq("Yesterday") end - it 'tracks search analytics' do + it 'returns song data' do + post :search, params: valid_params + + expect(JSON.parse(response.body)).to match( + hash_including('success' => true, + 'data' => hash_including('songs' => [hash_including('title' => 'Yesterday')])) + ) + end + + it 'creates analytics record' do expect { post :search, params: valid_params }.to change(SearchAnalytic, :count).by(1) - - analytic = SearchAnalytic.last - expect(analytic.user_id).to eq(user.id) - expect(analytic.query).to eq("Beatles") - expect(analytic.ip_address).to be_present + end + + it 'stores correct analytics data' do + post :search, params: valid_params + + expect(SearchAnalytic.last).to have_attributes(user_id: user.id, query: 'Beatles') end end @@ -85,13 +92,16 @@ }) end - it 'returns error response' do + it 'returns unprocessable content status' do post :search, params: valid_params - + expect(response).to have_http_status(:unprocessable_content) - json_response = JSON.parse(response.body) - expect(json_response['success']).to be false - expect(json_response['error']).to include("AI service temporarily unavailable") + end + + it 'returns fallback error message' do + post :search, params: valid_params + + expect(JSON.parse(response.body)['error']).to include('AI service temporarily unavailable') end end @@ -104,13 +114,16 @@ }) end - it 'returns rate limited response' do + it 'returns too many requests status' do post :search, params: valid_params - + expect(response).to have_http_status(:too_many_requests) - json_response = JSON.parse(response.body) - expect(json_response['success']).to be false - expect(json_response['rate_limited']).to be true + end + + it 'returns rate limit flag' do + post :search, params: valid_params + + expect(JSON.parse(response.body)['rate_limited']).to be true end end @@ -119,12 +132,16 @@ request.headers['Authorization'] = nil end - it 'returns unauthorized' do + it 'returns unauthorized status' do post :search, params: valid_params - + expect(response).to have_http_status(:unauthorized) - json_response = JSON.parse(response.body) - expect(json_response['error']).to eq('Missing token') + end + + it 'returns missing token message' do + post :search, params: valid_params + + expect(JSON.parse(response.body)['error']).to eq('Missing token') end end @@ -136,12 +153,16 @@ request.headers['Authorization'] = "Bearer #{unconfirmed_token}" end - it 'returns forbidden' do + it 'returns forbidden status' do post :search, params: valid_params - + expect(response).to have_http_status(:forbidden) - json_response = JSON.parse(response.body) - expect(json_response['error']).to eq('Access denied') + end + + it 'returns access denied message' do + post :search, params: valid_params + + expect(JSON.parse(response.body)['error']).to eq('Access denied') end end end @@ -156,28 +177,28 @@ create(:search_analytic, user: user1, query: "Pink Floyd", searched_at: 30.minutes.ago) end - it 'returns trending searches' do + it 'returns ok status for trending' do get :trending - + expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response['success']).to be true - expect(json_response['data']['trending_searches']).to be_present - expect(json_response['data']['trending_searches']['Beatles']).to eq(3) + end + + it 'returns trending data' do + get :trending + + expect(JSON.parse(response.body)['data']['trending_searches']).to include('Beatles' => 3) end it 'respects limit parameter' do get :trending, params: { limit: 2 } - - json_response = JSON.parse(response.body) - expect(json_response['data']['trending_searches'].keys.size).to eq(2) + + expect(JSON.parse(response.body)['data']['trending_searches'].keys.size).to eq(2) end it 'respects time_period parameter' do get :trending, params: { time_period: 1 } - - json_response = JSON.parse(response.body) - expect(json_response['data']['time_period_hours']).to eq(1) + + expect(JSON.parse(response.body)['data']['time_period_hours']).to eq(1) end end @@ -186,33 +207,35 @@ let!(:search2) { create(:search_analytic, user: user, query: "Queen", searched_at: 1.hour.ago) } let!(:other_user_search) { create(:search_analytic, query: "Pink Floyd", searched_at: 30.minutes.ago) } - it 'returns user search history' do + it 'returns ok status for user history' do get :user_history - + expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response['success']).to be true - expect(json_response['data']['searches'].size).to eq(2) - expect(json_response['data']['searches'].first['query']).to eq("Queen") # Most recent first + end + + it 'returns two user searches' do + get :user_history + + expect(JSON.parse(response.body)['data']['searches'].size).to eq(2) + end + + it 'returns most recent search first' do + get :user_history + + expect(JSON.parse(response.body)['data']['searches'].first['query']).to eq('Queen') end it 'paginates results' do get :user_history, params: { page: 1, per_page: 1 } - - json_response = JSON.parse(response.body) - expect(json_response['data']['searches'].size).to eq(1) - expect(json_response['data']['pagination']['page']).to eq(1) - expect(json_response['data']['pagination']['per_page']).to eq(1) - expect(json_response['data']['pagination']['total_count']).to eq(2) + + expect(JSON.parse(response.body)['data']['pagination']).to include('page' => 1, 'per_page' => 1, 'total_count' => 2) end it 'only returns current user searches' do get :user_history - - json_response = JSON.parse(response.body) - queries = json_response['data']['searches'].map { |s| s['query'] } - expect(queries).to contain_exactly("Beatles", "Queen") - expect(queries).not_to include("Pink Floyd") + + queries = JSON.parse(response.body)['data']['searches'].map { |s| s['query'] } + expect(queries).to match_array(%w[Beatles Queen]) end end end \ No newline at end of file diff --git a/spec/services/ai_search_service_spec.rb b/spec/services/ai_search_service_spec.rb index 7b7bded..7f21457 100644 --- a/spec/services/ai_search_service_spec.rb +++ b/spec/services/ai_search_service_spec.rb @@ -5,6 +5,8 @@ let(:query) { "Beatles" } let(:service) { AiSearchService.new(query: query, user_id: user.id, limit: 5) } + before { ENV['OPENAI_API_KEY'] = 'test-key' } + describe '#search_songs' do context 'with valid query' do before do @@ -13,27 +15,27 @@ ) end - it 'returns successful response with songs' do + it 'returns expected data' do result = service.search_songs - - expect(result[:success]).to be true - expect(result[:songs]).to be_an(Array) - expect(result[:songs].first[:title]).to eq("Yesterday") - expect(result[:songs].first[:artist]).to eq("The Beatles") - expect(result[:cached]).to be false + + expect(result).to match( + hash_including( + success: true, + cached: false, + songs: [a_hash_including(title: 'Yesterday', artist: 'The Beatles')] + ) + ) end it 'caches the results' do - expect(Rails.cache).to receive(:write).with(anything, anything, expires_in: 1.hour).at_least(:once) + expect(Rails.cache).to receive(:write).with(anything, anything, expires_in: 1.hour) service.search_songs end it 'tracks search analytics' do - expect { service.search_songs }.to change(SearchAnalytic, :count).by(1) - - analytic = SearchAnalytic.last - expect(analytic.user_id).to eq(user.id) - expect(analytic.query).to eq(query) + service.search_songs + + expect(SearchAnalytic.last).to have_attributes(user_id: user.id, query: query) end end @@ -47,10 +49,10 @@ it 'returns cached results' do result = service.search_songs - - expect(result[:success]).to be true - expect(result[:songs]).to eq(cached_songs) - expect(result[:cached]).to be true + + expect(result).to match( + hash_including(success: true, songs: cached_songs, cached: true) + ) end it 'does not call AI service when cached' do @@ -63,10 +65,9 @@ let(:query) { "" } it 'returns validation error' do - result = service.search_songs - - expect(result[:success]).to be false - expect(result[:error]).to eq("Query must be between 2 and 100 characters") + expect(service.search_songs).to match( + hash_including(success: false, error: "Query must be between 2 and 100 characters") + ) end end @@ -76,11 +77,13 @@ end it 'returns rate limit error' do - result = service.search_songs - - expect(result[:success]).to be false - expect(result[:error]).to eq("Rate limit exceeded. Please try again later.") - expect(result[:rate_limited]).to be true + expect(service.search_songs).to match( + hash_including( + success: false, + error: "Rate limit exceeded. Please try again later.", + rate_limited: true + ) + ) end end @@ -90,11 +93,13 @@ end it 'returns fallback response' do - result = service.search_songs - - expect(result[:success]).to be false - expect(result[:error]).to eq("AI service temporarily unavailable. Please try again later.") - expect(result[:fallback]).to be true + expect(service.search_songs).to match( + hash_including( + success: false, + error: "AI service temporarily unavailable. Please try again later.", + fallback: true + ) + ) end end end @@ -155,24 +160,21 @@ it 'parses valid JSON response' do service = AiSearchService.new(query: query, user_id: user.id) result = service.send(:parse_ai_response, valid_json_response) - - expect(result).to be_an(Array) - expect(result.first[:title]).to eq("Yesterday") - expect(result.first[:artist]).to eq("The Beatles") + + expect(result).to match([a_hash_including(title: 'Yesterday', artist: 'The Beatles')]) end it 'extracts JSON from response with extra text' do service = AiSearchService.new(query: query, user_id: user.id) result = service.send(:parse_ai_response, response_with_extra_text) - - expect(result).to be_an(Array) - expect(result.first[:title]).to eq("Yesterday") + + expect(result).to match([a_hash_including(title: 'Yesterday')]) end it 'returns empty array for invalid JSON' do service = AiSearchService.new(query: query, user_id: user.id) - result = service.send(:parse_ai_response, "invalid json") - + result = service.send(:parse_ai_response, 'invalid json') + expect(result).to eq([]) end end