From d90cf5c846faa5e9a0af635a8511d694d8417a83 Mon Sep 17 00:00:00 2001 From: Monika Dubel Date: Tue, 27 Jan 2026 18:23:16 +0100 Subject: [PATCH 1/4] feat: ai flashcards generation --- .ai/openrouter-service-implementation-plan.md | 1299 +++++++++++++++++ Gemfile | 4 + Gemfile.lock | 23 + README.md | 46 +- app/assets/stylesheets/application.css | 126 ++ app/controllers/flashcards_controller.rb | 30 +- app/controllers/generations_controller.rb | 215 ++- .../flashcard_review_controller.js | 60 + .../controllers/loading_controller.js | 28 + app/models/generation.rb | 18 + app/models/user.rb | 49 + app/services/flashcard_generation_service.rb | 146 ++ app/services/open_router_service.rb | 200 +++ app/views/flashcards/index.html.erb | 12 + app/views/generations/index.html.erb | 91 ++ app/views/generations/new.html.erb | 105 ++ app/views/generations/review.html.erb | 159 ++ app/views/generations/show.html.erb | 284 ++++ app/views/shared/_stats_header.html.erb | 115 ++ config/routes.rb | 8 +- ...add_generated_flashcards_to_generations.rb | 5 + ...60127163149_add_reviewed_to_generations.rb | 5 + db/schema.rb | 4 +- lib/tasks/test_openrouter.rake | 153 ++ spec/rails_helper.rb | 6 + spec/requests/generations_spec.rb | 298 +++- .../flashcard_generation_service_spec.rb | 179 +++ spec/services/open_router_service_spec.rb | 348 +++++ spec/system/flashcards_spec.rb | 4 +- 29 files changed, 3986 insertions(+), 34 deletions(-) create mode 100644 .ai/openrouter-service-implementation-plan.md create mode 100644 app/javascript/controllers/flashcard_review_controller.js create mode 100644 app/javascript/controllers/loading_controller.js create mode 100644 app/services/flashcard_generation_service.rb create mode 100644 app/services/open_router_service.rb create mode 100644 app/views/generations/index.html.erb create mode 100644 app/views/generations/new.html.erb create mode 100644 app/views/generations/review.html.erb create mode 100644 app/views/generations/show.html.erb create mode 100644 app/views/shared/_stats_header.html.erb create mode 100644 db/migrate/20260127161805_add_generated_flashcards_to_generations.rb create mode 100644 db/migrate/20260127163149_add_reviewed_to_generations.rb create mode 100644 lib/tasks/test_openrouter.rake create mode 100644 spec/services/flashcard_generation_service_spec.rb create mode 100644 spec/services/open_router_service_spec.rb diff --git a/.ai/openrouter-service-implementation-plan.md b/.ai/openrouter-service-implementation-plan.md new file mode 100644 index 0000000..ad59fc5 --- /dev/null +++ b/.ai/openrouter-service-implementation-plan.md @@ -0,0 +1,1299 @@ +# OpenRouter Service Implementation Plan + +## 1. Opis usługi + +`OpenRouterService` to usługa Ruby odpowiedzialna za komunikację z API OpenRouter.ai w celu realizacji zapytań do modeli językowych (LLM). Usługa ta enkapsuluje całą logikę związaną z formatowaniem żądań, obsługą odpowiedzi, oraz zarządzaniem błędami związanymi z komunikacją z zewnętrznym API. + +### Główne cele usługi: +- Abstrakcja szczegółów implementacji API OpenRouter +- Zapewnienie typobezpiecznego interfejsu do komunikacji z LLM +- Obsługa strukturyzowanych odpowiedzi poprzez JSON Schema +- Centralizacja obsługi błędów i logowania +- Umożliwienie łatwej konfiguracji parametrów modelu + +## 2. Opis konstruktora + +```ruby +def initialize(model:, temperature: 0.7, max_tokens: 2000, api_key: nil) +``` + +### Parametry konstruktora: + +1. **`model`** (String, wymagany) + - Nazwa modelu z katalogu OpenRouter (np. `"openai/gpt-4-turbo"`, `"anthropic/claude-3-opus"`) + - Pełna lista dostępnych modeli: https://openrouter.ai/models + +2. **`temperature`** (Float, opcjonalny, domyślnie: 0.7) + - Kontroluje losowość odpowiedzi (0.0 - 2.0) + - Niższe wartości = bardziej deterministyczne odpowiedzi + - Wyższe wartości = bardziej kreatywne odpowiedzi + +3. **`max_tokens`** (Integer, opcjonalny, domyślnie: 2000) + - Maksymalna liczba tokenów w odpowiedzi + - Zapobiega nadmiernym kosztom i zbyt długim odpowiedziom + +4. **`api_key`** (String, opcjonalny) + - Klucz API do OpenRouter + - Jeśli nie podano, używa `ENV['OPENROUTER_API_KEY']` + +### Przykład użycia: + +```ruby +# Podstawowe użycie z domyślnymi parametrami +service = OpenRouterService.new(model: "openai/gpt-4-turbo") + +# Pełna konfiguracja +service = OpenRouterService.new( + model: "anthropic/claude-3-opus", + temperature: 0.3, + max_tokens: 4000, + api_key: "sk-or-v1-..." +) +``` + +## 3. Publiczne metody i pola + +### 3.1. Metoda `complete` + +Główna metoda do wykonywania zapytań do modelu z możliwością strukturyzowania odpowiedzi. + +```ruby +def complete(system_message:, user_message:, response_format: nil) +``` + +#### Parametry: + +1. **`system_message`** (String, wymagany) + - Instrukcje systemowe definiujące zachowanie modelu + - Określa rolę, kontekst i ograniczenia + - Przykład: + ```ruby + system_message = <<~PROMPT + You are an expert educational content creator specializing in flashcard generation. + Create high-quality flashcards from the provided text that: + - Focus on key concepts and facts + - Use clear, concise language + - Include context when necessary + - Avoid ambiguity + PROMPT + ``` + +2. **`user_message`** (String, wymagany) + - Treść zapytania użytkownika + - Zawiera dane do przetworzenia przez model + - Przykład: + ```ruby + user_message = "Generate flashcards from the following text: #{source_text}" + ``` + +3. **`response_format`** (Hash, opcjonalny) + - Definiuje strukturę oczekiwanej odpowiedzi w formacie JSON Schema + - Zapewnia typobezpieczność i walidację odpowiedzi + - Format zgodny z OpenRouter API: + ```ruby + { + type: 'json_schema', + json_schema: { + name: 'flashcards_generation', + strict: true, + schema: { + type: 'object', + properties: { + flashcards: { + type: 'array', + items: { + type: 'object', + properties: { + question: { type: 'string' }, + answer: { type: 'string' } + }, + required: ['question', 'answer'], + additionalProperties: false + } + } + }, + required: ['flashcards'], + additionalProperties: false + } + } + } + ``` + +#### Zwracana wartość: + +- **Sukces**: Hash z parsowanymi danymi JSON +- **Błąd**: Zgłasza wyjątek `OpenRouterService::Error` lub jego podklasy + +#### Przykład użycia: + +```ruby +# Bez strukturyzowanej odpowiedzi +response = service.complete( + system_message: "You are a helpful assistant.", + user_message: "Explain photosynthesis in simple terms." +) +# => { "content" => "Photosynthesis is the process..." } + +# Ze strukturyzowaną odpowiedzią +response_format = { + type: 'json_schema', + json_schema: { + name: 'flashcards_list', + strict: true, + schema: { + type: 'object', + properties: { + flashcards: { + type: 'array', + items: { + type: 'object', + properties: { + question: { type: 'string' }, + answer: { type: 'string' } + }, + required: ['question', 'answer'], + additionalProperties: false + } + } + }, + required: ['flashcards'], + additionalProperties: false + } + } +} + +response = service.complete( + system_message: "Generate educational flashcards.", + user_message: "Create 3 flashcards about photosynthesis.", + response_format: response_format +) +# => { "flashcards" => [{"question" => "...", "answer" => "..."}, ...] } +``` + +### 3.2. Publiczne pola (getters) + +```ruby +attr_reader :model, :temperature, :max_tokens +``` + +Umożliwiają odczyt konfiguracji usługi po inicjalizacji. + +## 4. Prywatne metody i pola + +### 4.1. Pole `@api_key` + +```ruby +attr_reader :api_key +private :api_key +``` + +Przechowuje klucz API. Prywatne dla bezpieczeństwa. + +### 4.2. Metoda `build_request_body` + +```ruby +private + +def build_request_body(system_message:, user_message:, response_format:) +``` + +Konstruuje ciało żądania HTTP do API OpenRouter. + +#### Struktura zwracanego Hash: + +```ruby +{ + model: @model, + messages: [ + { role: "system", content: system_message }, + { role: "user", content: user_message } + ], + temperature: @temperature, + max_tokens: @max_tokens +} +``` + +Jeśli podano `response_format`, dodaje klucz `response_format` do Hash. + +### 4.3. Metoda `make_request` + +```ruby +private + +def make_request(body) +``` + +Wykonuje zapytanie HTTP POST do API OpenRouter. + +#### Odpowiedzialności: +- Ustawia odpowiednie nagłówki HTTP +- Serializuje body do JSON +- Wykonuje zapytanie z timeoutami +- Obsługuje błędy sieciowe +- Parsuje odpowiedź JSON + +#### Konfiguracja HTTP: +- Endpoint: `https://openrouter.ai/api/v1/chat/completions` +- Content-Type: `application/json` +- Authorization: `Bearer #{api_key}` +- HTTP-Referer: URL aplikacji (opcjonalny, dla statystyk) +- X-Title: Nazwa aplikacji (opcjonalny, dla statystyk) + +#### Timeouty: +- Open timeout: 5 sekund +- Read timeout: 60 sekund (dla dłuższych generacji) + +### 4.4. Metoda `parse_response` + +```ruby +private + +def parse_response(response) +``` + +Parsuje odpowiedź z API i ekstraktuje treść wiadomości. + +#### Logika: +1. Sprawdza status code odpowiedzi +2. Parsuje JSON +3. Ekstraktuje treść z `response['choices'][0]['message']['content']` +4. Jeśli użyto `response_format`, parsuje treść jako JSON +5. Zwraca sparsowane dane + +### 4.5. Metoda `handle_error_response` + +```ruby +private + +def handle_error_response(response) +``` + +Konwertuje kody błędów HTTP na odpowiednie wyjątki aplikacyjne. + +#### Mapowanie błędów: +- 400 Bad Request → `InvalidRequestError` +- 401 Unauthorized → `AuthenticationError` +- 402 Payment Required → `InsufficientCreditsError` +- 429 Too Many Requests → `RateLimitError` +- 500+ Server Errors → `ServerError` +- Inne → `APIError` + +## 5. Obsługa błędów + +### 5.1. Hierarchia wyjątków + +```ruby +class OpenRouterService + class Error < StandardError; end + class ConfigurationError < Error; end + class AuthenticationError < Error; end + class InvalidRequestError < Error; end + class RateLimitError < Error; end + class InsufficientCreditsError < Error; end + class ServerError < Error; end + class APIError < Error; end + class NetworkError < Error; end + class ResponseParseError < Error; end +end +``` + +### 5.2. Scenariusze błędów i ich obsługa + +#### Scenariusz 1: Brak klucza API +**Kiedy**: Brak zmiennej środowiskowej i parametru konstruktora +**Wyjątek**: `ConfigurationError` +**Komunikat**: `"OpenRouter API key is missing. Provide api_key parameter or set OPENROUTER_API_KEY environment variable."` + +#### Scenariusz 2: Nieprawidłowy klucz API +**Kiedy**: HTTP 401 +**Wyjątek**: `AuthenticationError` +**Komunikat**: `"Invalid API key. Please check your OpenRouter credentials."` + +#### Scenariusz 3: Nieprawidłowy format żądania +**Kiedy**: HTTP 400 +**Wyjątek**: `InvalidRequestError` +**Komunikat**: Szczegóły błędu z API + +#### Scenariusz 4: Limit żądań przekroczony +**Kiedy**: HTTP 429 +**Wyjątek**: `RateLimitError` +**Komunikat**: `"Rate limit exceeded. Please try again later."` +**Retry-After**: Header w odpowiedzi (jeśli dostępny) + +#### Scenariusz 5: Brak środków na koncie +**Kiedy**: HTTP 402 +**Wyjątek**: `InsufficientCreditsError` +**Komunikat**: `"Insufficient credits. Please add funds to your OpenRouter account."` + +#### Scenariusz 6: Timeout sieci +**Kiedy**: Net::OpenTimeout, Net::ReadTimeout +**Wyjątek**: `NetworkError` +**Komunikat**: `"Network timeout. The request took too long to complete."` + +#### Scenariusz 7: Błąd serwera OpenRouter +**Kiedy**: HTTP 500+ +**Wyjątek**: `ServerError` +**Komunikat**: `"OpenRouter server error. Please try again later."` + +#### Scenariusz 8: Nieprawidłowy format odpowiedzi +**Kiedy**: Błąd parsowania JSON +**Wyjątek**: `ResponseParseError` +**Komunikat**: `"Unable to parse API response."` + +### 5.3. Strategia obsługi błędów w aplikacji + +```ruby +# W kontrolerze +def create + @generation = current_user.generations.build(generation_params) + + return render_validation_error unless @generation.valid? + + begin + flashcards = generate_flashcards(@generation.source_text) + save_generation_with_flashcards(@generation, flashcards) + redirect_to generation_path(@generation), notice: "Flashcards generated successfully." + rescue OpenRouterService::RateLimitError => e + handle_rate_limit_error(e) + rescue OpenRouterService::InsufficientCreditsError => e + handle_insufficient_credits_error(e) + rescue OpenRouterService::NetworkError => e + handle_network_error(e) + rescue OpenRouterService::Error => e + handle_general_error(e) + end +end + +private + +def handle_rate_limit_error(error) + flash.now[:alert] = "Too many requests. Please try again in a few minutes." + render :new, status: :too_many_requests +end + +def handle_insufficient_credits_error(error) + flash.now[:alert] = "Service temporarily unavailable. Please try again later." + render :new, status: :service_unavailable +end + +def handle_network_error(error) + flash.now[:alert] = "Connection timeout. Please try again." + render :new, status: :request_timeout +end + +def handle_general_error(error) + Rails.logger.error("OpenRouter error: #{error.class} - #{error.message}") + flash.now[:alert] = "Failed to generate flashcards. Please try again." + render :new, status: :unprocessable_entity +end +``` + +## 6. Kwestie bezpieczeństwa + +### 6.1. Ochrona klucza API + +#### Dobre praktyki: +1. **Nigdy** nie commituj kluczy API do repozytorium +2. Używaj zmiennych środowiskowych (`ENV['OPENROUTER_API_KEY']`) +3. W produkcji używaj systemu zarządzania sekretami (AWS Secrets Manager, Rails credentials) +4. Rotuj klucze regularnie + +#### Konfiguracja w Rails: + +```ruby +# config/credentials.yml.enc (produkcja) +openrouter: + api_key: sk-or-v1-xxxxxxxxxxxxx + +# Dostęp w kodzie +api_key = Rails.application.credentials.dig(:openrouter, :api_key) +``` + +#### Plik .env dla development (z gem dotenv-rails): + +```bash +# .env (dodaj do .gitignore!) +OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxx +``` + +### 6.2. Walidacja i sanityzacja danych wejściowych + +```ruby +# W modelu +validates :source_text, presence: true, length: { minimum: 100, maximum: 10_000 } + +# Przed wysłaniem do API +def sanitize_input(text) + text.strip.gsub(/\s+/, ' ') # Usuń nadmiarowe białe znaki +end +``` + +### 6.3. Rate limiting po stronie aplikacji + +```ruby +# Implementacja prostego rate limitingu w cache +def check_rate_limit(user_id) + key = "openrouter:rate_limit:#{user_id}" + count = Rails.cache.read(key) || 0 + + if count >= 10 # 10 żądań na godzinę + raise OpenRouterService::RateLimitError, "User rate limit exceeded" + end + + Rails.cache.write(key, count + 1, expires_in: 1.hour) +end +``` + +### 6.4. Logowanie i monitoring + +```ruby +# Loguj wszystkie żądania do API (bez wrażliwych danych) +def log_api_request(model:, tokens_estimate:) + Rails.logger.info({ + service: 'OpenRouterService', + action: 'complete', + model: model, + tokens_estimate: tokens_estimate, + timestamp: Time.current + }.to_json) +end + +# Loguj błędy z kontekstem +def log_api_error(error, context = {}) + Rails.logger.error({ + service: 'OpenRouterService', + error_class: error.class.name, + error_message: error.message, + context: context, + backtrace: error.backtrace.first(5), + timestamp: Time.current + }.to_json) +end +``` + +### 6.5. Timeout i resource limits + +```ruby +# Zapobiegaj zawieszeniu aplikacji +http.open_timeout = 5 # Maksymalnie 5s na połączenie +http.read_timeout = 60 # Maksymalnie 60s na odpowiedź + +# Limit maksymalnej liczby tokenów +MAX_TOKENS_LIMIT = 4000 +``` + +## 7. Plan wdrożenia krok po kroku + +### Krok 1: Przygotowanie środowiska + +#### 1.1. Dodanie zależności do Gemfile + +```ruby +# Gemfile +gem 'httparty', '~> 0.21' # Do zapytań HTTP (alternatywa: 'faraday') +``` + +Zainstaluj: +```bash +bundle install +``` + +#### 1.2. Konfiguracja zmiennych środowiskowych + +```bash +# .env (development) +OPENROUTER_API_KEY=sk-or-v1-your-key-here + +# .env.example (commituj do repo) +OPENROUTER_API_KEY= +``` + +Dodaj `.env` do `.gitignore`. + +#### 1.3. Konfiguracja Rails credentials (production) + +```bash +EDITOR="vim" rails credentials:edit + +# Dodaj: +# openrouter: +# api_key: sk-or-v1-production-key +``` + +### Krok 2: Utworzenie struktury usługi + +#### 2.1. Utwórz katalog dla usług + +```bash +mkdir -p app/services +``` + +#### 2.2. Utwórz plik usługi + +```bash +touch app/services/openrouter_service.rb +``` + +### Krok 3: Implementacja podstawowej struktury usługi + +```ruby +# app/services/openrouter_service.rb + +class OpenRouterService + API_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions' + DEFAULT_TEMPERATURE = 0.7 + DEFAULT_MAX_TOKENS = 2000 + DEFAULT_TIMEOUT = 60 + + attr_reader :model, :temperature, :max_tokens + + # Hierarchia wyjątków + class Error < StandardError; end + class ConfigurationError < Error; end + class AuthenticationError < Error; end + class InvalidRequestError < Error; end + class RateLimitError < Error; end + class InsufficientCreditsError < Error; end + class ServerError < Error; end + class APIError < Error; end + class NetworkError < Error; end + class ResponseParseError < Error; end + + def initialize(model:, temperature: DEFAULT_TEMPERATURE, max_tokens: DEFAULT_MAX_TOKENS, api_key: nil) + @model = model + @temperature = temperature + @max_tokens = max_tokens + @api_key = api_key || fetch_api_key + + validate_configuration! + end + + def complete(system_message:, user_message:, response_format: nil) + body = build_request_body( + system_message: system_message, + user_message: user_message, + response_format: response_format + ) + + response = make_request(body) + parse_response(response, structured: response_format.present?) + end + + private + + attr_reader :api_key + + def fetch_api_key + Rails.application.credentials.dig(:openrouter, :api_key) || + ENV['OPENROUTER_API_KEY'] + end + + def validate_configuration! + if api_key.blank? + raise ConfigurationError, + "OpenRouter API key is missing. Provide api_key parameter or set OPENROUTER_API_KEY." + end + + if model.blank? + raise ConfigurationError, "Model name is required." + end + end + + def build_request_body(system_message:, user_message:, response_format:) + body = { + model: model, + messages: [ + { role: "system", content: system_message }, + { role: "user", content: user_message } + ], + temperature: temperature, + max_tokens: max_tokens + } + + body[:response_format] = response_format if response_format.present? + body + end + + def make_request(body) + # Implementacja w następnym kroku + end + + def parse_response(response, structured:) + # Implementacja w następnym kroku + end + + def handle_error_response(status_code, body) + # Implementacja w następnym kroku + end +end +``` + +### Krok 4: Implementacja zapytań HTTP + +#### 4.1. Opcja A: Użycie Net::HTTP (bez dodatkowych gemów) + +```ruby +require 'net/http' +require 'json' + +private + +def make_request(body) + uri = URI(API_ENDPOINT) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = 5 + http.read_timeout = DEFAULT_TIMEOUT + + request = Net::HTTP::Post.new(uri.path) + request['Content-Type'] = 'application/json' + request['Authorization'] = "Bearer #{api_key}" + request['HTTP-Referer'] = Rails.application.routes.url_helpers.root_url + request['X-Title'] = 'Flashcards App' + request.body = body.to_json + + response = http.request(request) + + return response if response.is_a?(Net::HTTPSuccess) + + handle_error_response(response.code.to_i, response.body) +rescue Net::OpenTimeout, Net::ReadTimeout => e + raise NetworkError, "Network timeout: #{e.message}" +rescue StandardError => e + raise APIError, "Unexpected error: #{e.message}" +end +``` + +#### 4.2. Opcja B: Użycie HTTParty + +```ruby +require 'httparty' + +private + +def make_request(body) + response = HTTParty.post( + API_ENDPOINT, + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}", + 'HTTP-Referer' => Rails.application.routes.url_helpers.root_url, + 'X-Title' => 'Flashcards App' + }, + body: body.to_json, + timeout: DEFAULT_TIMEOUT + ) + + return response if response.success? + + handle_error_response(response.code, response.body) +rescue HTTParty::TimeoutError => e + raise NetworkError, "Network timeout: #{e.message}" +rescue StandardError => e + raise APIError, "Unexpected error: #{e.message}" +end +``` + +### Krok 5: Implementacja parsowania odpowiedzi + +```ruby +private + +def parse_response(response, structured:) + body = response.is_a?(String) ? JSON.parse(response) : response.parsed_response + + content = body.dig('choices', 0, 'message', 'content') + + if content.blank? + raise ResponseParseError, "No content in API response" + end + + # Jeśli użyto strukturyzowanej odpowiedzi, parsuj jako JSON + if structured + JSON.parse(content) + else + { 'content' => content } + end +rescue JSON::ParserError => e + raise ResponseParseError, "Failed to parse response: #{e.message}" +end + +def handle_error_response(status_code, body) + error_message = extract_error_message(body) + + case status_code + when 400 + raise InvalidRequestError, error_message + when 401 + raise AuthenticationError, "Invalid API key. Please check your OpenRouter credentials." + when 402 + raise InsufficientCreditsError, "Insufficient credits. Please add funds to your OpenRouter account." + when 429 + raise RateLimitError, "Rate limit exceeded. Please try again later." + when 500..599 + raise ServerError, "OpenRouter server error (#{status_code}). Please try again later." + else + raise APIError, "API error (#{status_code}): #{error_message}" + end +end + +def extract_error_message(body) + parsed = JSON.parse(body) + parsed.dig('error', 'message') || 'Unknown error' +rescue JSON::ParserError + body.to_s +end +``` + +### Krok 6: Utworzenie obiektu service dla generacji fiszek + +#### 6.1. Utwórz dedykowany service object + +```bash +touch app/services/flashcard_generation_service.rb +``` + +#### 6.2. Implementacja FlashcardGenerationService + +```ruby +# app/services/flashcard_generation_service.rb + +class FlashcardGenerationService + SYSTEM_PROMPT = <<~PROMPT + You are an expert educational content creator specializing in flashcard generation. + Your task is to create high-quality flashcards from the provided text. + + Guidelines: + - Focus on key concepts, definitions, and important facts + - Each question should be clear and unambiguous + - Answers should be concise but complete + - Include context in the question when necessary + - Avoid yes/no questions; prefer questions that require understanding + - Generate between 5 and 15 flashcards depending on content richness + - Ensure questions test understanding, not just memorization + PROMPT + + RESPONSE_FORMAT = { + type: 'json_schema', + json_schema: { + name: 'flashcards_generation', + strict: true, + schema: { + type: 'object', + properties: { + flashcards: { + type: 'array', + items: { + type: 'object', + properties: { + question: { + type: 'string', + description: 'The question on the front of the flashcard' + }, + answer: { + type: 'string', + description: 'The answer on the back of the flashcard' + } + }, + required: ['question', 'answer'], + additionalProperties: false + } + } + }, + required: ['flashcards'], + additionalProperties: false + } + } + }.freeze + + def initialize(model: 'openai/gpt-4-turbo', temperature: 0.7) + @openrouter = OpenRouterService.new( + model: model, + temperature: temperature, + max_tokens: 3000 + ) + end + + def generate(source_text) + raise ArgumentError, "Source text cannot be blank" if source_text.blank? + + user_message = build_user_message(source_text) + + response = @openrouter.complete( + system_message: SYSTEM_PROMPT, + user_message: user_message, + response_format: RESPONSE_FORMAT + ) + + validate_and_extract_flashcards(response) + end + + private + + def build_user_message(source_text) + <<~MESSAGE + Generate educational flashcards from the following text: + + #{source_text} + + Create flashcards that will help a student learn and retain the key information from this text. + MESSAGE + end + + def validate_and_extract_flashcards(response) + flashcards = response['flashcards'] + + if flashcards.blank? || !flashcards.is_a?(Array) + raise OpenRouterService::ResponseParseError, "Invalid flashcards format" + end + + flashcards.map do |card| + { + question: card['question'].strip, + answer: card['answer'].strip + } + end + end +end +``` + +### Krok 7: Integracja z kontrolerem + +#### 7.1. Aktualizacja GenerationsController + +```ruby +# app/controllers/generations_controller.rb + +class GenerationsController < ApplicationController + before_action :authenticate_user! + + def create + @generation = current_user.generations.build(generation_params) + + unless @generation.valid? + flash.now[:alert] = "Please check the errors below." + return render :new, status: :unprocessable_entity + end + + begin + flashcards_data = generate_flashcards(@generation.source_text) + save_generation_with_flashcards(@generation, flashcards_data) + + redirect_to generation_path(@generation), + notice: "Successfully generated #{flashcards_data.count} flashcards." + rescue OpenRouterService::RateLimitError + handle_rate_limit_error + rescue OpenRouterService::InsufficientCreditsError + handle_insufficient_credits_error + rescue OpenRouterService::NetworkError + handle_network_error + rescue OpenRouterService::Error => e + handle_api_error(e) + end + end + + private + + def generate_flashcards(source_text) + service = FlashcardGenerationService.new + service.generate(source_text) + end + + def save_generation_with_flashcards(generation, flashcards_data) + ActiveRecord::Base.transaction do + generation.save! + + flashcards_data.each do |card_data| + generation.flashcards.create!( + user: current_user, + question: card_data[:question], + answer: card_data[:answer] + ) + end + end + end + + def handle_rate_limit_error + flash.now[:alert] = "Too many requests. Please try again in a few minutes." + render :new, status: :too_many_requests + end + + def handle_insufficient_credits_error + flash.now[:alert] = "Service temporarily unavailable. Please contact support." + render :new, status: :service_unavailable + end + + def handle_network_error + flash.now[:alert] = "Connection timeout. Please check your internet connection and try again." + render :new, status: :request_timeout + end + + def handle_api_error(error) + Rails.logger.error("OpenRouter API error: #{error.class} - #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + + flash.now[:alert] = "Failed to generate flashcards. Please try again or contact support if the problem persists." + render :new, status: :unprocessable_entity + end + + def generation_params + params.require(:generation).permit(:source_text) + end +end +``` + +### Krok 8: Testy jednostkowe + +#### 8.1. Testy dla OpenRouterService + +```bash +touch spec/services/openrouter_service_spec.rb +``` + +```ruby +# spec/services/openrouter_service_spec.rb + +require 'rails_helper' + +RSpec.describe OpenRouterService do + describe '#initialize' do + it 'raises ConfigurationError when API key is missing' do + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(nil) + allow(Rails.application.credentials).to receive(:dig).with(:openrouter, :api_key).and_return(nil) + + expect { + described_class.new(model: 'openai/gpt-4') + }.to raise_error(OpenRouterService::ConfigurationError, /API key is missing/) + end + + it 'accepts API key from environment variable' do + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return('test-key') + + service = described_class.new(model: 'openai/gpt-4') + + expect(service.model).to eq('openai/gpt-4') + end + + it 'uses default parameters when not provided' do + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return('test-key') + + service = described_class.new(model: 'openai/gpt-4') + + expect(service.temperature).to eq(0.7) + expect(service.max_tokens).to eq(2000) + end + end + + describe '#complete' do + let(:service) do + described_class.new( + model: 'openai/gpt-4', + api_key: 'test-key' + ) + end + + let(:system_message) { 'You are a helpful assistant.' } + let(:user_message) { 'Hello, world!' } + + context 'when request is successful' do + it 'returns parsed response' do + stub_successful_response + + response = service.complete( + system_message: system_message, + user_message: user_message + ) + + expect(response).to eq({ 'content' => 'Hello! How can I help you?' }) + end + end + + context 'when using structured output' do + let(:response_format) do + { + type: 'json_schema', + json_schema: { + name: 'test_schema', + strict: true, + schema: { + type: 'object', + properties: { + message: { type: 'string' } + }, + required: ['message'] + } + } + } + end + + it 'returns structured response' do + stub_structured_response + + response = service.complete( + system_message: system_message, + user_message: user_message, + response_format: response_format + ) + + expect(response).to eq({ 'message' => 'Hello! How can I help you?' }) + end + end + + context 'when API returns error' do + it 'raises AuthenticationError on 401' do + stub_error_response(401, 'Invalid API key') + + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_error_response(429, 'Rate limit exceeded') + + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::RateLimitError) + end + end + end + + private + + def stub_successful_response + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 200, + body: { + choices: [ + { message: { content: 'Hello! How can I help you?' } } + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_structured_response + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 200, + body: { + choices: [ + { message: { content: '{"message":"Hello! How can I help you?"}' } } + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_error_response(status, message) + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: status, + body: { error: { message: message } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end +end +``` + +#### 8.2. Dodanie webmock do Gemfile + +```ruby +# Gemfile +group :test do + gem 'webmock' +end +``` + +```bash +bundle install +``` + +#### 8.3. Konfiguracja webmock + +```ruby +# spec/rails_helper.rb +require 'webmock/rspec' + +RSpec.configure do |config| + config.before(:each) do + WebMock.disable_net_connect!(allow_localhost: true) + end +end +``` + +### Krok 9: Monitoring i logowanie + +#### 9.1. Dodanie logowania do usługi + +```ruby +# app/services/openrouter_service.rb + +def complete(system_message:, user_message:, response_format: nil) + log_request_start(user_message) + + body = build_request_body( + system_message: system_message, + user_message: user_message, + response_format: response_format + ) + + response = make_request(body) + result = parse_response(response, structured: response_format.present?) + + log_request_success + result +rescue OpenRouterService::Error => e + log_request_error(e) + raise +end + +private + +def log_request_start(user_message) + Rails.logger.info({ + service: 'OpenRouterService', + action: 'complete', + model: model, + message_length: user_message.length, + timestamp: Time.current + }.to_json) +end + +def log_request_success + Rails.logger.info({ + service: 'OpenRouterService', + action: 'complete', + status: 'success', + timestamp: Time.current + }.to_json) +end + +def log_request_error(error) + Rails.logger.error({ + service: 'OpenRouterService', + action: 'complete', + status: 'error', + error_class: error.class.name, + error_message: error.message, + timestamp: Time.current + }.to_json) +end +``` + +### Krok 10: Dokumentacja użycia + +#### 10.1. Utwórz dokumentację + +```bash +touch docs/openrouter_service_usage.md +``` + +#### 10.2. Przykłady użycia + +```markdown +# OpenRouter Service - Przewodnik użycia + +## Podstawowe użycie + +### Proste zapytanie + +```ruby +service = OpenRouterService.new(model: 'openai/gpt-4-turbo') + +response = service.complete( + system_message: 'You are a helpful assistant.', + user_message: 'Explain quantum computing in simple terms.' +) + +puts response['content'] +``` + +### Strukturyzowana odpowiedź + +```ruby +response_format = { + type: 'json_schema', + json_schema: { + name: 'explanation', + strict: true, + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + key_points: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['summary', 'key_points'] + } + } +} + +response = service.complete( + system_message: 'Provide structured explanations.', + user_message: 'Explain photosynthesis.', + response_format: response_format +) + +puts response['summary'] +response['key_points'].each { |point| puts "- #{point}" } +``` + +## Obsługa błędów + +```ruby +begin + response = service.complete( + system_message: system_msg, + user_message: user_msg + ) +rescue OpenRouterService::RateLimitError + # Poczekaj i spróbuj ponownie + sleep 60 + retry +rescue OpenRouterService::InsufficientCreditsError + # Powiadom administratora + AdminMailer.insufficient_credits.deliver_now +rescue OpenRouterService::Error => e + # Ogólna obsługa błędów + Rails.logger.error(e.message) + raise +end +``` +``` + +### Krok 11: Checklist wdrożenia + +- [ ] Dodano gem do obsługi HTTP (httparty lub użycie Net::HTTP) +- [ ] Skonfigurowano zmienne środowiskowe dla development +- [ ] Skonfigurowano Rails credentials dla production +- [ ] Utworzono katalog `app/services` +- [ ] Zaimplementowano `OpenRouterService` z wszystkimi metodami +- [ ] Zaimplementowano `FlashcardGenerationService` +- [ ] Zaktualizowano `GenerationsController` +- [ ] Dodano obsługę błędów w kontrolerze +- [ ] Napisano testy jednostkowe dla usług +- [ ] Dodano logowanie żądań i błędów +- [ ] Przetestowano ręcznie w środowisku development +- [ ] Utworzono dokumentację użycia +- [ ] Skonfigurowano monitoring (opcjonalnie) +- [ ] Przeprowadzono code review +- [ ] Wdrożono na production + +## Podsumowanie + +Ten plan wdrożenia zapewnia: +- ✅ Kompletną implementację komunikacji z OpenRouter API +- ✅ Typobezpieczne strukturyzowane odpowiedzi przez JSON Schema +- ✅ Kompleksową obsługę błędów z dedykowanymi wyjątkami +- ✅ Bezpieczeństwo poprzez zarządzanie sekretami +- ✅ Logowanie i monitoring dla celów debugowania +- ✅ Separation of concerns (usługa ogólna + usługa domenowa) +- ✅ Testy jednostkowe dla zapewnienia jakości +- ✅ Dokumentację dla przyszłych deweloperów + +Implementacja ta jest zgodna z Rails best practices, zasadami SOLID oraz wytycznymi projektu dotyczącymi service objects i thin controllers. diff --git a/Gemfile b/Gemfile index 216f37a..f139414 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,8 @@ gem "image_processing", "~> 1.2" gem "tailwindcss-rails" gem "tailwindcss-ruby" gem "devise" +gem "httparty", "~> 0.21" +gem "will_paginate", "~> 4.0" group :development, :test do gem "rubocop", require: false @@ -31,6 +33,7 @@ group :development, :test do gem "factory_bot_rails" gem "faker" gem "rails-controller-testing" + gem "dotenv-rails" end group :development do @@ -43,4 +46,5 @@ group :test do gem "selenium-webdriver" gem "simplecov", require: false gem "simplecov-json", require: false + gem "webmock" end diff --git a/Gemfile.lock b/Gemfile.lock index 2769812..a2dc4aa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -110,7 +110,11 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.6) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) + csv (3.3.5) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -124,6 +128,9 @@ GEM diff-lcs (1.6.2) docile (1.4.1) dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) drb (2.2.3) ed25519 (1.4.0) erb (6.0.1) @@ -156,6 +163,11 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + hashdiff (1.2.1) + httparty (0.24.2) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -206,6 +218,8 @@ GEM minitest (6.0.1) prism (~> 1.5) msgpack (1.8.0) + multi_xml (0.8.1) + bigdecimal (>= 3.1, < 5) net-imap (0.6.2) date net-protocol @@ -449,11 +463,16 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + will_paginate (4.0.1) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.4) @@ -476,9 +495,11 @@ DEPENDENCIES capybara debug devise + dotenv-rails erb_lint factory_bot_rails faker + httparty (~> 0.21) image_processing (~> 1.2) importmap-rails jbuilder @@ -505,6 +526,8 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + webmock + will_paginate (~> 4.0) BUNDLED WITH 2.6.9 diff --git a/README.md b/README.md index b8650ff..c3da908 100644 --- a/README.md +++ b/README.md @@ -50,27 +50,36 @@ CI/CD and Hosting: Clone the repository: -``` +```bash git clone https://github.com/your-org/Flashcards.git cd Flashcards ``` Install dependencies: -``` +```bash bundle install ``` -Set up the database: +Configure OpenRouter API (required for AI flashcard generation): + +```bash +# Set environment variable for development +export OPENROUTER_API_KEY='sk-or-v1-your-key-here' +# Get your API key from: https://openrouter.ai ``` -bin/rails db:create db:migrate + +Set up the database: + +```bash +bin/rails db:create db:migrate db:seed ``` Start the development server: -``` -bin/rails server +```bash +bin/dev ``` The application will be available at http://localhost:3000. @@ -79,11 +88,12 @@ The application will be available at http://localhost:3000. Common Rails commands used in this project: -``` -bin/rails server # Start the development server +```bash +bin/dev # Start development server (recommended) +bin/rails server # Start Rails server only bin/rails console # Open Rails console bin/rails db:migrate # Run database migrations -bin/rails test # Run test suite +bundle exec rspec # Run test suite ``` Linting and security tools (development/test): @@ -122,6 +132,24 @@ Out of scope for MVP: The project is currently in the MVP stage and under active development. +## Services Architecture + +### OpenRouterService + +Low-level API client for OpenRouter.ai communication: +- Handles HTTP requests and authentication +- Manages structured JSON responses +- Comprehensive error handling +- SSL configuration for development + +### FlashcardGenerationService + +High-level service for AI flashcard generation: +- Intelligent prompt engineering +- Response validation and formatting +- Quality control (length, content) +- Multiple model support + ## License This project is licensed under the MIT License. diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..40ebbd8 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -8,3 +8,129 @@ * * Consider organizing styles into separate files for maintainability. */ + +/* will_paginate Tailwind Styling */ + +.pagination { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0; + margin: 0; + list-style: none; + background-color: #ffffff; + border-radius: 0.75rem; + padding: 0.375rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); +} + +.pagination a, +.pagination .current, +.pagination .disabled, +.pagination span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.75rem; + height: 2.75rem; + padding: 0 0.875rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + transition: all 0.15s ease-in-out; + line-height: 1; +} + +.pagination a { + color: #4b5563; + background-color: transparent; + text-decoration: none; +} + +.pagination a:hover { + background-color: #f3f4f6; + color: #111827; + transform: translateY(-1px); +} + +.pagination a:active { + transform: translateY(0); +} + +.pagination .current { + color: #ffffff; + background-color: #4f46e5; + font-weight: 600; + box-shadow: 0 4px 6px -1px rgb(79 70 229 / 0.3), 0 2px 4px -2px rgb(79 70 229 / 0.3); +} + +.pagination .disabled { + color: #d1d5db; + background-color: transparent; + cursor: not-allowed; + opacity: 0.5; +} + +.pagination .previous_page, +.pagination .next_page { + padding: 0 1.25rem; + font-weight: 600; + color: #4f46e5; +} + +.pagination .previous_page:hover, +.pagination .next_page:hover { + background-color: #eef2ff; + color: #4338ca; +} + +.pagination .previous_page.disabled, +.pagination .next_page.disabled { + color: #d1d5db; +} + +.pagination .previous_page.disabled:hover, +.pagination .next_page.disabled:hover { + background-color: transparent; + transform: none; +} + +.pagination em { + font-style: normal; +} + +.pagination .gap { + color: #9ca3af; + padding: 0 0.5rem; + font-weight: 600; +} + +/* Loading Spinner Animations */ + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} diff --git a/app/controllers/flashcards_controller.rb b/app/controllers/flashcards_controller.rb index 536e602..7aa2673 100644 --- a/app/controllers/flashcards_controller.rb +++ b/app/controllers/flashcards_controller.rb @@ -2,7 +2,22 @@ class FlashcardsController < ApplicationController before_action :authenticate_user! def index - @flashcards = current_user.flashcards + @flashcards = current_user.flashcards.order(created_at: :desc).paginate(page: params[:page], per_page: 12) + + # User-specific stats + @user_stats = { + ai_acceptance_rate: current_user.ai_acceptance_rate, + ai_flashcards_percentage: current_user.ai_flashcards_percentage, + total_flashcards: current_user.flashcards.count + } + + # System-wide stats + @system_stats = { + ai_acceptance_rate: User.system_ai_acceptance_rate, + ai_flashcards_percentage: User.system_ai_flashcards_percentage, + total_flashcards: User.system_total_flashcards, + total_users: User.system_total_users + } end def show @@ -32,7 +47,18 @@ def edit def update @flashcard = current_user.flashcards.find(params[:id]) - if @flashcard.update(flashcard_params) + # Check if flashcard content changed and it was AI-generated + content_changed = (@flashcard.front != flashcard_params[:front] || + @flashcard.back != flashcard_params[:back]) + + # Change source to ai_edited if it was ai_full and content changed + if content_changed && @flashcard.ai_full? + updated_params = flashcard_params.merge(source: :ai_edited) + else + updated_params = flashcard_params + end + + if @flashcard.update(updated_params) redirect_to flashcards_path, notice: "Flashcard updated successfully." else flash.now[:alert] = "Failed to update flashcard. Please check the errors below." diff --git a/app/controllers/generations_controller.rb b/app/controllers/generations_controller.rb index a26af2f..cfbb690 100644 --- a/app/controllers/generations_controller.rb +++ b/app/controllers/generations_controller.rb @@ -1,32 +1,213 @@ class GenerationsController < ApplicationController - before_action :authenticate_user! + before_action :authenticate_user! - def index - @generations = current_user.generations + def index + @generations = current_user.generations.order(created_at: :desc).paginate(page: params[:page], per_page: 10) + end + + def show + @generation = current_user.generations.find(params[:id]) + end + + def new + @generation = Generation.new + end + + def create + @generation = current_user.generations.build(generation_params) + + unless @generation.valid? + flash.now[:alert] = "Please check the errors below." + return render :new, status: :unprocessable_entity + end + + begin + ActiveRecord::Base.transaction do + result = generate_flashcards(@generation.source_text) + flashcards_data = result[:flashcards] + metadata = result[:metadata] + + # Save generation with generated flashcards data and metadata for review + @generation.generated_flashcards = flashcards_data + @generation.model = metadata[:model] + @generation.generation_duration = metadata[:generation_duration] + @generation.generated_count = metadata[:generated_count] + @generation.save! + + redirect_to review_generation_path(@generation), + notice: "Generated #{flashcards_data.count} flashcards. Review and select which ones to save." + end + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = "Failed to save generation: #{e.message}" + render :new, status: :unprocessable_entity + rescue OpenRouterService::RateLimitError + handle_rate_limit_error + rescue OpenRouterService::InsufficientCreditsError + handle_insufficient_credits_error + rescue OpenRouterService::NetworkError + handle_network_error + rescue OpenRouterService::AuthenticationError + handle_authentication_error + rescue OpenRouterService::Error => e + handle_api_error(e) end + end - def show - @generation = current_user.generations.find(params[:id]) + def review + @generation = current_user.generations.find(params[:id]) + + # Check if already reviewed + if @generation.reviewed? + flash[:alert] = "This generation has already been reviewed. Flashcards cannot be edited after review." + return redirect_to generation_path(@generation) + end + + @flashcards_data = @generation.generated_flashcards + + # If no generated flashcards data, redirect to show page + if @flashcards_data.empty? + flash[:alert] = "No flashcard data available for review. This generation may have been created with an older version." + redirect_to generation_path(@generation) end + end - def new - @generation = Generation.new + def save_flashcards + @generation = current_user.generations.find(params[:id]) + flashcards_params = params.fetch(:flashcards, {}) + + if flashcards_params.empty? + flash[:alert] = "No flashcards to save." + return redirect_to review_generation_path(@generation) end - def create - @generation = current_user.generations.build(generation_params) + begin + saved_count = 0 + unedited_count = 0 + edited_count = 0 + original_flashcards = @generation.generated_flashcards || [] + + ActiveRecord::Base.transaction do + flashcards_params.each do |index, card_params| + next unless card_params[:selected] == "1" + + # Determine if flashcard was edited + original_card = original_flashcards[index.to_i] + was_edited = false + + if original_card + original_front = original_card[:front] || original_card["front"] + original_back = original_card[:back] || original_card["back"] + was_edited = (card_params[:front] != original_front || card_params[:back] != original_back) + end - if @generation.save - redirect_to generation_path(@generation), notice: "Generation created successfully." - else - flash.now[:alert] = "Failed to create generation. Please check the errors below." - render :new, status: :unprocessable_entity + @generation.flashcards.create!( + user: current_user, + front: card_params[:front], + back: card_params[:back], + source: was_edited ? :ai_edited : :ai_full + ) + + if was_edited + edited_count += 1 + else + unedited_count += 1 + end + saved_count += 1 end + + # Update acceptance counts + @generation.update!( + reviewed: true, + accepted_unedited_count: unedited_count, + accepted_edited_count: edited_count + ) + end + + if saved_count.zero? + flash[:alert] = "No flashcards were selected. Please select at least one flashcard." + redirect_to review_generation_path(@generation) + else + redirect_to generation_path(@generation), + notice: "Successfully saved #{saved_count} flashcard#{'s' unless saved_count == 1}." + end + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = "Error saving flashcards: #{e.message}" + @flashcards_data = @generation.generated_flashcards || [] + render :review, status: :unprocessable_entity end + end + + def save_all_flashcards + @generation = current_user.generations.find(params[:id]) + flashcards_data = @generation.generated_flashcards || [] - private + begin + saved_count = 0 - def generation_params - params.require(:generation).permit(:source_text) + ActiveRecord::Base.transaction do + flashcards_data.each do |card_data| + @generation.flashcards.create!( + user: current_user, + front: card_data[:front] || card_data["front"], + back: card_data[:back] || card_data["back"], + source: :ai_full + ) + saved_count += 1 + end + + # All flashcards are unedited when saving all + @generation.update!( + reviewed: true, + accepted_unedited_count: saved_count, + accepted_edited_count: 0 + ) + end + + redirect_to generation_path(@generation), + notice: "Successfully saved all #{saved_count} flashcards." + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = "Error saving flashcards: #{e.message}" + redirect_to review_generation_path(@generation) end + end + + private + + def generate_flashcards(source_text) + service = FlashcardGenerationService.new + service.generate(source_text) + end + + def handle_rate_limit_error + flash.now[:alert] = "Too many requests. Please try again in a few minutes." + render :new, status: :too_many_requests + end + + def handle_insufficient_credits_error + flash.now[:alert] = "Service temporarily unavailable. Please try again later." + render :new, status: :service_unavailable + end + + def handle_network_error + flash.now[:alert] = "Connection timeout. Please check your internet connection and try again." + render :new, status: :request_timeout + end + + def handle_authentication_error + Rails.logger.error("OpenRouter authentication failed. Check API key configuration.") + flash.now[:alert] = "Service configuration error. Please contact support." + render :new, status: :service_unavailable + end + + def handle_api_error(error) + Rails.logger.error("OpenRouter API error: #{error.class} - #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + + flash.now[:alert] = "Failed to generate flashcards. Please try again or contact support if the problem persists." + render :new, status: :unprocessable_entity + end + + def generation_params + params.require(:generation).permit(:source_text) + end end diff --git a/app/javascript/controllers/flashcard_review_controller.js b/app/javascript/controllers/flashcard_review_controller.js new file mode 100644 index 0000000..91c33e4 --- /dev/null +++ b/app/javascript/controllers/flashcard_review_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["checkbox", "item", "count"] + + connect() { + console.log('Flashcard Review Controller Connected') + console.log('Checkboxes found:', this.checkboxTargets.length) + console.log('Items found:', this.itemTargets.length) + console.log('Count target found:', this.hasCountTarget) + + this.updateSelectedCount() + this.updateVisualState() + } + + selectAll(event) { + event.preventDefault() + console.log('Select All clicked') + this.checkboxTargets.forEach(checkbox => { + checkbox.checked = true + }) + this.updateSelectedCount() + this.updateVisualState() + } + + deselectAll(event) { + event.preventDefault() + console.log('Deselect All clicked') + this.checkboxTargets.forEach(checkbox => { + checkbox.checked = false + }) + this.updateSelectedCount() + this.updateVisualState() + } + + toggleSelection() { + this.updateSelectedCount() + this.updateVisualState() + } + + updateSelectedCount() { + const count = this.checkboxTargets.filter(cb => cb.checked).length + if (this.hasCountTarget) { + this.countTarget.textContent = count + } + } + + updateVisualState() { + this.itemTargets.forEach((item, index) => { + const checkbox = this.checkboxTargets[index] + if (checkbox && checkbox.checked) { + item.classList.add('border-indigo-500', 'bg-indigo-50') + item.classList.remove('border-transparent') + } else { + item.classList.remove('border-indigo-500', 'bg-indigo-50') + item.classList.add('border-transparent') + } + }) + } +} diff --git a/app/javascript/controllers/loading_controller.js b/app/javascript/controllers/loading_controller.js new file mode 100644 index 0000000..6ab9811 --- /dev/null +++ b/app/javascript/controllers/loading_controller.js @@ -0,0 +1,28 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["spinner", "submitButton"] + + connect() { + // Hide spinner on initial load + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.add("hidden") + } + } + + submit(event) { + // Show the loading spinner + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.remove("hidden") + } + + // Disable the submit button and change its text + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.disabled = true + this.submitButtonTarget.classList.add("opacity-50", "cursor-not-allowed") + } + + // Scroll to top so user can see the loading spinner + window.scrollTo({ top: 0, behavior: "smooth" }) + } +} diff --git a/app/models/generation.rb b/app/models/generation.rb index 91ad55f..27d6758 100644 --- a/app/models/generation.rb +++ b/app/models/generation.rb @@ -3,4 +3,22 @@ class Generation < ApplicationRecord has_many :flashcards, dependent: :nullify validates :source_text, presence: true, length: { minimum: 1000, maximum: 10_000 } + + # Ensure generated_flashcards is always an array + def generated_flashcards + value = super + return [] if value.nil? + return value if value.is_a?(Array) + + # If it's a string, try to parse it as JSON + if value.is_a?(String) + begin + JSON.parse(value) + rescue JSON::ParserError + [] + end + else + [] + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index f93c621..e0aa70d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,4 +6,53 @@ class User < ApplicationRecord has_many :generations, dependent: :destroy has_many :flashcards, dependent: :destroy + + # Calculate AI acceptance rate for this user + # (accepted_unedited_count + accepted_edited_count) / generated_count * 100 + def ai_acceptance_rate + total_generated = generations.sum(:generated_count) + return 0 if total_generated.zero? + + total_accepted = generations.sum(:accepted_unedited_count).to_i + + generations.sum(:accepted_edited_count).to_i + + (total_accepted.to_f / total_generated * 100).round(1) + end + + # Calculate percentage of flashcards that are AI-generated for this user + # COUNT(flashcards WHERE source IN ('ai_full', 'ai_edited')) / COUNT(flashcards) * 100 + def ai_flashcards_percentage + total_flashcards = flashcards.count + return 0 if total_flashcards.zero? + + ai_flashcards = flashcards.where(source: [ :ai_full, :ai_edited ]).count + (ai_flashcards.to_f / total_flashcards * 100).round(1) + end + + # System-wide statistics (class methods) + def self.system_ai_acceptance_rate + total_generated = Generation.sum(:generated_count) + return 0 if total_generated.zero? + + total_accepted = Generation.sum(:accepted_unedited_count).to_i + + Generation.sum(:accepted_edited_count).to_i + + (total_accepted.to_f / total_generated * 100).round(1) + end + + def self.system_ai_flashcards_percentage + total_flashcards = Flashcard.count + return 0 if total_flashcards.zero? + + ai_flashcards = Flashcard.where(source: [ :ai_full, :ai_edited ]).count + (ai_flashcards.to_f / total_flashcards * 100).round(1) + end + + def self.system_total_flashcards + Flashcard.count + end + + def self.system_total_users + User.count + end end diff --git a/app/services/flashcard_generation_service.rb b/app/services/flashcard_generation_service.rb new file mode 100644 index 0000000..1cb01e2 --- /dev/null +++ b/app/services/flashcard_generation_service.rb @@ -0,0 +1,146 @@ +class FlashcardGenerationService + SYSTEM_PROMPT = <<~PROMPT + You are an expert educational content creator specializing in flashcard generation. + Your task is to create high-quality flashcards from the provided text. + + Guidelines: + - Focus on key concepts, definitions, and important facts + - Each question should be clear and unambiguous + - Answers should be concise but complete + - Include context in the question when necessary + - Avoid yes/no questions; prefer questions that require understanding + - Generate between 5 and 15 flashcards depending on content richness + - Ensure questions test understanding, not just memorization + - Questions should be self-contained (include necessary context) + - Answers should be specific and accurate + PROMPT + + RESPONSE_FORMAT = { + type: "json_schema", + json_schema: { + name: "flashcards_generation", + strict: true, + schema: { + type: "object", + properties: { + flashcards: { + type: "array", + items: { + type: "object", + properties: { + question: { + type: "string", + description: "The question on the front of the flashcard" + }, + answer: { + type: "string", + description: "The answer on the back of the flashcard" + } + }, + required: [ "question", "answer" ], + additionalProperties: false + } + } + }, + required: [ "flashcards" ], + additionalProperties: false + } + } + }.freeze + + # Modele które wspierają structured output + DEFAULT_MODEL = "openai/gpt-4o-mini" + FALLBACK_MODEL = "openai/gpt-4o" + + def initialize(model: DEFAULT_MODEL, temperature: 0.7) + @model = model + @temperature = temperature + end + + def generate(source_text) + raise ArgumentError, "Source text cannot be blank" if source_text.blank? + + start_time = Time.current + user_message = build_user_message(source_text) + + response = openrouter_service.complete( + system_message: SYSTEM_PROMPT, + user_message: user_message, + response_format: RESPONSE_FORMAT + ) + + flashcards = validate_and_extract_flashcards(response) + end_time = Time.current + duration_ms = ((end_time - start_time) * 1000).to_i + + { + flashcards: flashcards, + metadata: { + model: @model, + generation_duration: duration_ms, + generated_count: flashcards.count + } + } + end + + private + + def openrouter_service + @openrouter_service ||= OpenRouterService.new( + model: @model, + temperature: @temperature, + max_tokens: 3000, + skip_ssl_verify: Rails.env.development? # Tylko dla developmentu, false w produkcji + ) + end + + def build_user_message(source_text) + <<~MESSAGE + Generate educational flashcards from the following text: + + #{source_text} + + Create flashcards that will help a student learn and retain the key information from this text. + Focus on the most important concepts and facts. + MESSAGE + end + + def validate_and_extract_flashcards(response) + flashcards = response["flashcards"] + + if flashcards.blank? || !flashcards.is_a?(Array) + raise OpenRouterService::ResponseParseError, "Invalid flashcards format in response" + end + + if flashcards.empty? + raise OpenRouterService::ResponseParseError, "No flashcards generated" + end + + flashcards.map do |card| + validate_flashcard!(card) + + { + front: card["question"].strip, + back: card["answer"].strip + } + end + end + + def validate_flashcard!(card) + if card["question"].blank? + raise OpenRouterService::ResponseParseError, "Flashcard question cannot be blank" + end + + if card["answer"].blank? + raise OpenRouterService::ResponseParseError, "Flashcard answer cannot be blank" + end + + if card["question"].length > 200 + raise OpenRouterService::ResponseParseError, "Flashcard question too long (max 200 characters)" + end + + if card["answer"].length > 500 + raise OpenRouterService::ResponseParseError, "Flashcard answer too long (max 500 characters)" + end + end +end diff --git a/app/services/open_router_service.rb b/app/services/open_router_service.rb new file mode 100644 index 0000000..f13bf7a --- /dev/null +++ b/app/services/open_router_service.rb @@ -0,0 +1,200 @@ +require "net/http" +require "json" + +class OpenRouterService + API_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" + DEFAULT_TEMPERATURE = 0.7 + DEFAULT_MAX_TOKENS = 2000 + DEFAULT_TIMEOUT = 60 + + attr_reader :model, :temperature, :max_tokens + + # Hierarchia wyjątków + class Error < StandardError; end + class ConfigurationError < Error; end + class AuthenticationError < Error; end + class InvalidRequestError < Error; end + class RateLimitError < Error; end + class InsufficientCreditsError < Error; end + class ServerError < Error; end + class APIError < Error; end + class NetworkError < Error; end + class ResponseParseError < Error; end + + def initialize(model:, temperature: DEFAULT_TEMPERATURE, max_tokens: DEFAULT_MAX_TOKENS, api_key: nil, skip_ssl_verify: true) + @model = model + @temperature = temperature + @max_tokens = max_tokens + @api_key = api_key || fetch_api_key + @skip_ssl_verify = skip_ssl_verify + + validate_configuration! + end + + def complete(system_message:, user_message:, response_format: nil) + body = build_request_body( + system_message: system_message, + user_message: user_message, + response_format: response_format + ) + + response = make_request(body) + parse_response(response, structured: response_format.present?) + end + + private + + attr_reader :api_key + + def fetch_api_key + Rails.application.credentials.dig(:openrouter, :api_key) || + ENV["OPENROUTER_API_KEY"] + end + + def validate_configuration! + if api_key.blank? + raise ConfigurationError, + "OpenRouter API key is missing. Provide api_key parameter or set OPENROUTER_API_KEY." + end + + if model.blank? + raise ConfigurationError, "Model name is required." + end + end + + def build_request_body(system_message:, user_message:, response_format:) + body = { + model: model, + messages: [ + { role: "system", content: system_message }, + { role: "user", content: user_message } + ], + temperature: temperature, + max_tokens: max_tokens + } + + body[:response_format] = response_format if response_format.present? + body + end + + def make_request(body) + uri = URI(API_ENDPOINT) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = 5 + http.read_timeout = DEFAULT_TIMEOUT + + # UWAGA: skip_ssl_verify powinno być używane TYLKO w developmencie + # W produkcji ZAWSZE używaj weryfikacji SSL + if @skip_ssl_verify + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + Rails.logger.warn("⚠️ SSL verification is disabled for OpenRouterService. This should ONLY be used in development!") + end + + request = Net::HTTP::Post.new(uri.path) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{api_key}" + request["HTTP-Referer"] = Rails.application.routes.url_helpers.root_url rescue "https://flashcards.app" + request["X-Title"] = "Flashcards App" + request.body = body.to_json + + if Rails.env.development? + Rails.logger.debug("OpenRouter Request:") + Rails.logger.debug(" Model: #{body[:model]}") + Rails.logger.debug(" Temperature: #{body[:temperature]}") + Rails.logger.debug(" Max tokens: #{body[:max_tokens]}") + Rails.logger.debug(" System message: #{body[:messages][0][:content][0..100]}...") + Rails.logger.debug(" User message: #{body[:messages][1][:content][0..100]}...") + end + + response = http.request(request) + + if Rails.env.development? + Rails.logger.debug("OpenRouter Response:") + Rails.logger.debug(" Status: #{response.code}") + Rails.logger.debug(" Body: #{response.body[0..500]}...") + end + + return response if response.is_a?(Net::HTTPSuccess) + + handle_error_response(response.code.to_i, response.body) + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise NetworkError, "Network timeout: #{e.message}" + rescue OpenRouterService::Error + raise # Re-raise our own errors + rescue StandardError => e + Rails.logger.error("OpenRouter Unexpected Error: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(5).join("\n")) + raise APIError, "Unexpected error: #{e.message}" + end + + def parse_response(response, structured:) + body = JSON.parse(response.body) + + # Sprawdź czy jest błąd w body (nawet przy HTTP 200) + if body["error"].present? + error_message = body.dig("error", "message") || "Unknown error from provider" + error_code = body.dig("error", "code") || "unknown" + + Rails.logger.error("OpenRouter Provider Error: #{error_code} - #{error_message}") + Rails.logger.error("Full error body: #{body['error'].inspect}") + + # Próbuj zmapować błąd providera na nasze wyjątki + case error_code + when 400, "invalid_request_error" + raise InvalidRequestError, error_message + when 401, "authentication_error" + raise AuthenticationError, error_message + when 429, "rate_limit_error" + raise RateLimitError, error_message + when 502, 503 + raise ServerError, "Provider error: #{error_message}" + else + raise APIError, "API error (#{error_code}): #{error_message}" + end + end + + content = body.dig("choices", 0, "message", "content") + + if content.blank? + Rails.logger.error("No content in response. Full body: #{body.inspect}") + raise ResponseParseError, "No content in API response. Response: #{body.inspect}" + end + + # Jeśli użyto strukturyzowanej odpowiedzi, parsuj jako JSON + if structured + JSON.parse(content) + else + { "content" => content } + end + rescue JSON::ParserError => e + raise ResponseParseError, "Failed to parse response: #{e.message}" + end + + def handle_error_response(status_code, body) + error_message = extract_error_message(body) + + case status_code + when 400 + raise InvalidRequestError, error_message + when 401 + raise AuthenticationError, "Invalid API key. Please check your OpenRouter credentials." + when 402 + raise InsufficientCreditsError, "Insufficient credits. Please add funds to your OpenRouter account." + when 429 + raise RateLimitError, "Rate limit exceeded. Please try again later." + when 500..599 + raise ServerError, "OpenRouter server error (#{status_code}). Please try again later." + else + raise APIError, "API error (#{status_code}): #{error_message}" + end + end + + def extract_error_message(body) + parsed = JSON.parse(body) + parsed.dig("error", "message") || "Unknown error" + rescue JSON::ParserError + body.to_s + end +end diff --git a/app/views/flashcards/index.html.erb b/app/views/flashcards/index.html.erb index 077b4a1..1af0909 100644 --- a/app/views/flashcards/index.html.erb +++ b/app/views/flashcards/index.html.erb @@ -1,4 +1,6 @@
+ <%= render partial: 'shared/stats_header', locals: { user_stats: @user_stats, system_stats: @system_stats } %> + <% if @flashcards.empty? %>
@@ -88,5 +90,15 @@
<% end %>
+ + +
+ <%= will_paginate @flashcards, + class: "inline-flex items-center gap-2", + previous_label: "← Previous", + next_label: "Next →", + inner_window: 2, + outer_window: 1 %> +
<% end %> diff --git a/app/views/generations/index.html.erb b/app/views/generations/index.html.erb new file mode 100644 index 0000000..037f5f4 --- /dev/null +++ b/app/views/generations/index.html.erb @@ -0,0 +1,91 @@ +
+
+

AI Generations

+

Your flashcard generation history

+
+ + <% if @generations.empty? %> +
+ + + +

No generations yet

+

Generate your first set of flashcards with AI

+ <%= link_to new_generation_path, class: "inline-flex items-center px-6 py-3 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors duration-200 shadow-sm" do %> + + + + Generate with AI + <% end %> +
+ <% else %> +
+ <% @generations.each do |generation| %> + <%= link_to generation_path(generation), class: "block" do %> +
+
+
+ +
+ + + + + AI Generated + + + + + + <%= pluralize(generation.flashcards.count, 'card') %> + +
+ + +
+

Source Text

+

<%= generation.source_text %>

+
+ + +
+ + + + Generated <%= time_ago_in_words(generation.created_at) %> ago +
+
+ + +
+ + + +
+
+
+ <% end %> + <% end %> +
+ + +
+ <%= will_paginate @generations, + class: "inline-flex items-center gap-2", + previous_label: "← Previous", + next_label: "Next →", + inner_window: 2, + outer_window: 1 %> +
+ + +
+ <%= link_to new_generation_path, class: "inline-flex items-center px-6 py-3 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors duration-200 shadow-sm" do %> + + + + Generate New Flashcards + <% end %> +
+ <% end %> +
diff --git a/app/views/generations/new.html.erb b/app/views/generations/new.html.erb new file mode 100644 index 0000000..debfc3d --- /dev/null +++ b/app/views/generations/new.html.erb @@ -0,0 +1,105 @@ +
+ + + +
+
+

Generate Flashcards with AI

+

Paste your text and let AI create flashcards for you

+
+ + <%= form_with model: @generation, url: generations_path, class: "space-y-6", data: { action: "turbo:submit-start->loading#submit" } do |f| %> +
+ <%= f.label :source_text, "Source Text", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= f.text_area :source_text, + rows: 15, + placeholder: "Paste the text you want to convert into flashcards...\n\nMinimum 1000 characters required.", + class: "w-full px-4 py-3 border rounded-lg transition resize-none font-mono text-sm #{@generation.errors[:source_text].any? ? 'border-red-300 bg-red-50 focus:ring-2 focus:ring-red-500 focus:border-transparent' : 'border-gray-300 focus:ring-2 focus:ring-indigo-500 focus:border-transparent'}", + maxlength: 10000 %> + <% if @generation.errors[:source_text].any? %> +

+ <%= @generation.errors.full_messages_for(:source_text).join(', ') %> +

+ <% else %> +

+ Minimum 1000 characters, maximum 10,000 characters +

+ <% end %> +
+ + +
+
+ + + +
+

Tips for best results:

+
    +
  • Paste well-structured educational content
  • +
  • Include clear concepts and definitions
  • +
  • The more context you provide, the better the flashcards
  • +
+
+
+
+ +
+ <%= f.submit "Generate Flashcards", + data: { loading_target: "submitButton" }, + class: "flex-1 py-3 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors duration-200 shadow-sm" %> + <%= link_to "Cancel", flashcards_path, + class: "flex-1 py-3 px-4 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium rounded-lg transition-colors duration-200 text-center" %> +
+ <% end %> +
+ +
+

+ Or + <%= link_to "create flashcards manually", new_flashcard_path, class: "text-indigo-600 hover:text-indigo-700 font-medium transition" %> +

+
+
diff --git a/app/views/generations/review.html.erb b/app/views/generations/review.html.erb new file mode 100644 index 0000000..2041f3e --- /dev/null +++ b/app/views/generations/review.html.erb @@ -0,0 +1,159 @@ +
+ +
+
+
+
+

Review Generated Flashcards

+

+ Select the flashcards you want to save. You can edit them before saving. +

+
+ + <%= pluralize(@flashcards_data.count, 'flashcard') %> generated + +
+
+ + +
+

Source Text

+
+

<%= @generation.source_text %>

+
+
+
+ +
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + + +
+
+
+ + + + 0 selected + +
+
+ <%= link_to "Cancel", generations_path, + class: "inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition" %> + + +
+
+
+ + +
+ <% @flashcards_data.each_with_index do |card_data, index| %> + <% + front = card_data[:front] || card_data["front"] + back = card_data[:back] || card_data["back"] + %> +
+
+ +
+ + + + + + AI Generated + +
+ + +
+ + <%= text_area_tag "flashcards[#{index}][front]", front, + rows: 3, + class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm text-gray-900 bg-white placeholder-gray-400 resize-none", + placeholder: "Front of the flashcard" %> +
+ + +
+ + <%= text_area_tag "flashcards[#{index}][back]", back, + rows: 4, + class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm text-gray-900 bg-white placeholder-gray-400 resize-none", + placeholder: "Back of the flashcard" %> +
+
+
+ <% end %> +
+ + +
+
+ <%= link_to "Cancel", generations_path, + class: "inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition" %> + + +
+
+
+ + + +
diff --git a/app/views/generations/show.html.erb b/app/views/generations/show.html.erb new file mode 100644 index 0000000..a6a8e90 --- /dev/null +++ b/app/views/generations/show.html.erb @@ -0,0 +1,284 @@ +
+ +
+ +
+
+
+
+ + + + + AI Generated + + + + + + <%= pluralize(@generation.flashcards.count, 'flashcard') %> generated + +
+

Generation Results

+

+ + + + Generated <%= time_ago_in_words(@generation.created_at) %> ago +

+
+
+
+ + + <% if @generation.model.present? %> +
+

Generation Statistics

+
+ +
+
+ + + + AI Model +
+

<%= @generation.model %>

+
+ + +
+
+ + + + Generation Time +
+

+ <% if @generation.generation_duration.present? %> + <% duration_seconds = @generation.generation_duration / 1000.0 %> + <%= number_with_precision(duration_seconds, precision: 2) %>s + <% else %> + N/A + <% end %> +

+
+ + +
+
+ + + + Cards Generated +
+

+ <%= @generation.generated_count || 0 %> +

+
+
+ + + <% if @generation.reviewed? && (@generation.accepted_unedited_count.present? || @generation.accepted_edited_count.present?) %> +
+
+ +
+
+ + + + Accepted Unedited +
+

+ <%= @generation.accepted_unedited_count || 0 %> +

+
+ + +
+
+ + + + Accepted Edited +
+

+ <%= @generation.accepted_edited_count || 0 %> +

+
+ + +
+
+ + + + Acceptance Rate +
+

+ <% if @generation.generated_count && @generation.generated_count > 0 %> + <% total_accepted = (@generation.accepted_unedited_count || 0) + (@generation.accepted_edited_count || 0) %> + <% acceptance_rate = (total_accepted.to_f / @generation.generated_count * 100).round(1) %> + <%= acceptance_rate %>% + <% else %> + N/A + <% end %> +

+
+
+
+ <% end %> +
+ <% end %> + + +
+

Source Text

+
+

<%= @generation.source_text %>

+
+
+
+ + + <% unless @generation.reviewed? %> +
+
+
+ + + +
+

Review Required

+

+ This generation has not been reviewed yet. Click the button below to review and select which flashcards to save. +

+ <%= link_to review_generation_path(@generation), + class: "inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700 transition shadow-sm" do %> + + + + Review Flashcards + <% end %> +
+
+
+
+ <% end %> + + +
+
+

Generated Flashcards

+ <% if @generation.reviewed? %> + + + + + Reviewed + + <% end %> +
+ + <% if @generation.flashcards.any? %> +
+ <% @generation.flashcards.each do |flashcard| %> +
+ +
+ <% if flashcard.ai_edited? %> + + + + + AI Edited + + <% elsif flashcard.ai_full? %> + + + + + AI Generated + + <% else %> + + + + + Manual + + <% end %> + + +
+ <%= link_to edit_flashcard_path(flashcard), + class: "p-1.5 text-gray-600 hover:text-indigo-600 hover:bg-gray-100 rounded-lg transition", + title: "Edit" do %> + + + + <% end %> + <%= button_to flashcard_path(flashcard), method: :delete, + data: { turbo_confirm: "Are you sure you want to delete this flashcard?" }, + class: "p-1.5 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition cursor-pointer", + title: "Delete", + form: { class: "inline" } do %> + + + + <% end %> +
+
+ + <%= link_to flashcard_path(flashcard), class: "block" do %> + +
+

Question

+

<%= flashcard.front %>

+
+ + +
+

Answer

+

<%= flashcard.back %>

+
+ <% end %> +
+ <% end %> +
+ <% else %> +
+ + + +

No flashcards were generated for this attempt.

+
+ <% end %> +
+ + +
+ <%= link_to generations_path, class: "inline-flex items-center text-sm text-gray-600 hover:text-gray-900 transition" do %> + + + + Back to all generations + <% end %> + +
+ <%= link_to flashcards_path, class: "inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition shadow-sm" do %> + + + + View All Cards + <% end %> + <%= link_to new_generation_path, class: "inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition shadow-sm" do %> + + + + Generate More + <% end %> +
+
+
diff --git a/app/views/shared/_stats_header.html.erb b/app/views/shared/_stats_header.html.erb new file mode 100644 index 0000000..4aa0998 --- /dev/null +++ b/app/views/shared/_stats_header.html.erb @@ -0,0 +1,115 @@ +
+ +
+

+ ✨ AI-Powered Learning +

+

+ Join thousands of learners creating flashcards effortlessly with AI +

+
+ + +
+ +
+
+ + + +

Your Stats

+
+ +
+ +
+
+ AI Acceptance Rate + <%= user_stats[:ai_acceptance_rate] %>% +
+
+
+
+
+ + +
+
+ AI-Generated Cards + <%= user_stats[:ai_flashcards_percentage] %>% +
+
+
+
+
+ + +
+ Total Cards + <%= user_stats[:total_flashcards] %> +
+
+
+ + +
+
+ + + +

Community Stats

+
+ +
+ +
+
+ AI Acceptance Rate + <%= system_stats[:ai_acceptance_rate] %>% +
+
+
+
+
+ + +
+
+ AI-Generated Cards + <%= system_stats[:ai_flashcards_percentage] %>% +
+
+
+
+
+ + +
+
+
Total Cards
+
<%= number_with_delimiter(system_stats[:total_flashcards]) %>
+
+
+
Active Users
+
<%= system_stats[:total_users] %>
+
+
+
+
+
+ + +
+

+ <% if user_stats[:ai_flashcards_percentage] < 75 %> + 💡 Tip: Use AI generation to create cards faster! Our target is 75% AI-generated cards. + <% else %> + 🎉 Amazing! You're making great use of AI-powered learning! + <% end %> +

+
+
diff --git a/config/routes.rb b/config/routes.rb index b338907..147bdf4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,5 +2,11 @@ devise_for :users root "flashcards#index" resources :flashcards - resources :generations, only: [ :index, :show, :create, :new ] + resources :generations, only: [ :index, :show, :create, :new ] do + member do + get :review + post :save_flashcards + post :save_all_flashcards + end + end end diff --git a/db/migrate/20260127161805_add_generated_flashcards_to_generations.rb b/db/migrate/20260127161805_add_generated_flashcards_to_generations.rb new file mode 100644 index 0000000..a9b9177 --- /dev/null +++ b/db/migrate/20260127161805_add_generated_flashcards_to_generations.rb @@ -0,0 +1,5 @@ +class AddGeneratedFlashcardsToGenerations < ActiveRecord::Migration[8.1] + def change + add_column :generations, :generated_flashcards, :jsonb + end +end diff --git a/db/migrate/20260127163149_add_reviewed_to_generations.rb b/db/migrate/20260127163149_add_reviewed_to_generations.rb new file mode 100644 index 0000000..339f81a --- /dev/null +++ b/db/migrate/20260127163149_add_reviewed_to_generations.rb @@ -0,0 +1,5 @@ +class AddReviewedToGenerations < ActiveRecord::Migration[8.1] + def change + add_column :generations, :reviewed, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index c109225..67bc886 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_01_14_150244) do +ActiveRecord::Schema[8.1].define(version: 2026_01_27_163149) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -31,8 +31,10 @@ t.integer "accepted_unedited_count" t.datetime "created_at", null: false t.integer "generated_count", default: 0, null: false + t.jsonb "generated_flashcards" t.integer "generation_duration" t.string "model" + t.boolean "reviewed", default: false, null: false t.text "source_text", null: false t.datetime "updated_at", null: false t.bigint "user_id", null: false diff --git a/lib/tasks/test_openrouter.rake b/lib/tasks/test_openrouter.rake new file mode 100644 index 0000000..96a89e2 --- /dev/null +++ b/lib/tasks/test_openrouter.rake @@ -0,0 +1,153 @@ +namespace :openrouter do + desc "Test OpenRouterService basic functionality" + task test_basic: :environment do + puts "\n=== Testing OpenRouterService ===\n\n" + + # Check API key + api_key = ENV["OPENROUTER_API_KEY"] + if api_key.blank? + puts "❌ ERROR: OPENROUTER_API_KEY environment variable is not set." + puts " Please set it with: export OPENROUTER_API_KEY='your-key'" + exit 1 + end + + puts "✓ API key found: #{api_key[0..15]}..." + + # Test 1: Initialization + puts "\n--- Test 1: Initialization ---" + begin + service = OpenRouterService.new( + model: "openai/gpt-4-turbo", + skip_ssl_verify: Rails.env.development? + ) + puts "✓ Service initialized successfully" + puts " Model: #{service.model}" + puts " Temperature: #{service.temperature}" + puts " Max tokens: #{service.max_tokens}" + rescue => e + puts "❌ ERROR: #{e.class} - #{e.message}" + exit 1 + end + + # Test 2: Simple completion + puts "\n--- Test 2: Simple completion ---" + begin + response = service.complete( + system_message: "You are a helpful assistant.", + user_message: 'Say "Hello World" and nothing else.' + ) + puts "✓ Request completed successfully" + puts " Response: #{response['content']}" + rescue OpenRouterService::Error => e + puts "❌ API ERROR: #{e.class} - #{e.message}" + exit 1 + rescue => e + puts "❌ UNEXPECTED ERROR: #{e.class} - #{e.message}" + puts e.backtrace.first(5) + exit 1 + end + + # Test 3: Structured response (flashcards) + puts "\n--- Test 3: Structured response (flashcards) ---" + begin + response_format = { + type: "json_schema", + json_schema: { + name: "flashcards_generation", + strict: true, + schema: { + type: "object", + properties: { + flashcards: { + type: "array", + items: { + type: "object", + properties: { + question: { type: "string" }, + answer: { type: "string" } + }, + required: [ "question", "answer" ], + additionalProperties: false + } + } + }, + required: [ "flashcards" ], + additionalProperties: false + } + } + } + + source_text = "Ruby on Rails is a web application framework written in Ruby. It follows the Model-View-Controller (MVC) pattern." + + response = service.complete( + system_message: "You are an expert educational content creator. Generate exactly 2 flashcards.", + user_message: "Generate flashcards from this text:\n\n#{source_text}", + response_format: response_format + ) + + puts "✓ Structured response received" + puts " Number of flashcards: #{response['flashcards'].length}" + + response["flashcards"].each_with_index do |card, index| + puts "\n Flashcard #{index + 1}:" + puts " Q: #{card['question']}" + puts " A: #{card['answer']}" + end + rescue OpenRouterService::Error => e + puts "❌ API ERROR: #{e.class} - #{e.message}" + exit 1 + rescue => e + puts "❌ UNEXPECTED ERROR: #{e.class} - #{e.message}" + puts e.backtrace.first(5) + exit 1 + end + + puts "\n\n=== All tests passed! ✓ ===\n" + end + + desc "Test OpenRouterService error handling" + task test_errors: :environment do + puts "\n=== Testing OpenRouterService Error Handling ===\n\n" + + # Test 1: Missing API key + puts "--- Test 1: Missing API key ---" + begin + service = OpenRouterService.new(model: "openai/gpt-4-turbo", api_key: "") + puts "❌ Should have raised ConfigurationError" + rescue OpenRouterService::ConfigurationError => e + puts "✓ Correctly raised ConfigurationError" + puts " Message: #{e.message}" + end + + # Test 2: Missing model + puts "\n--- Test 2: Missing model ---" + begin + service = OpenRouterService.new(model: "", api_key: "test-key") + puts "❌ Should have raised ConfigurationError" + rescue OpenRouterService::ConfigurationError => e + puts "✓ Correctly raised ConfigurationError" + puts " Message: #{e.message}" + end + + # Test 3: Invalid API key (if user wants to test with real API) + if ENV["TEST_INVALID_KEY"] == "true" + puts "\n--- Test 3: Invalid API key (real API call) ---" + begin + service = OpenRouterService.new(model: "openai/gpt-4-turbo", api_key: "invalid-key") + response = service.complete( + system_message: "Test", + user_message: "Hello" + ) + puts "❌ Should have raised AuthenticationError" + rescue OpenRouterService::AuthenticationError => e + puts "✓ Correctly raised AuthenticationError" + puts " Message: #{e.message}" + end + else + puts "\n--- Test 3: Invalid API key (skipped) ---" + puts " Set TEST_INVALID_KEY=true to test with real API" + end + + puts "\n\n=== Error handling tests passed! ✓ ===\n" + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 30d7b83..8591f3a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -28,6 +28,7 @@ # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' +require 'webmock/rspec' # Add additional requires below this line. Rails is not loaded until this point! # Requires supporting ruby files with custom matchers and macros, etc, in @@ -54,6 +55,11 @@ end RSpec.configure do |config| + # Configure WebMock + config.before(:each) do + WebMock.disable_net_connect!(allow_localhost: true) + end + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = [ Rails.root.join('spec/fixtures') diff --git a/spec/requests/generations_spec.rb b/spec/requests/generations_spec.rb index 9e950b9..f258ca1 100644 --- a/spec/requests/generations_spec.rb +++ b/spec/requests/generations_spec.rb @@ -1,7 +1,301 @@ require 'rails_helper' RSpec.describe "Generations", type: :request do - describe "GET /index" do - pending "add some examples (or delete) #{__FILE__}" + let(:user) { create(:user) } + let(:valid_source_text) { 'A' * 1500 } # Minimum 1000 characters + + before do + sign_in user + end + + describe "GET /generations" do + it "returns http success" do + get generations_path + expect(response).to have_http_status(:success) + end + + it "displays user's generations" do + generation1 = create(:generation, user: user, source_text: valid_source_text) + generation2 = create(:generation, user: user, source_text: valid_source_text) + + get generations_path + + expect(response.body).to include(generation1.id.to_s) + expect(response.body).to include(generation2.id.to_s) + end + + it "does not display other users' generations" do + other_user = create(:user) + other_generation = create(:generation, user: other_user, source_text: valid_source_text) + + get generations_path + + expect(response.body).not_to include(other_generation.id.to_s) + end + end + + describe "GET /generations/:id" do + let(:generation) { create(:generation, user: user, source_text: valid_source_text) } + + it "returns http success" do + get generation_path(generation) + expect(response).to have_http_status(:success) + end + + it "displays generation details" do + get generation_path(generation) + expect(response.body).to include(generation.source_text[0..50]) + end + + context "when generation belongs to another user" do + let(:other_user) { create(:user) } + let(:other_generation) { create(:generation, user: other_user, source_text: valid_source_text) } + + it "renders 404 page when trying to access another user's generation" do + get generation_path(other_generation) + expect(response).to have_http_status(:not_found) + expect(response.body).to include('Not Found') + end + end + end + + describe "GET /generations/new" do + it "returns http success" do + get new_generation_path + expect(response).to have_http_status(:success) + end + end + + describe "POST /generations" do + let(:source_text) { 'Rails is a web framework. ' * 100 } # ~2700 chars + + context "when generation is successful" do + before do + # Mock FlashcardGenerationService with new response format + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'What is Rails?', back: 'A web framework' }, + { front: 'What language is Rails written in?', back: 'Ruby' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1500, + generated_count: 2 + } + }) + end + + it "creates a new generation" do + expect { + post generations_path, params: { generation: { source_text: source_text } } + }.to change(Generation, :count).by(1) + end + + it "stores generated flashcards data" do + post generations_path, params: { generation: { source_text: source_text } } + + generation = Generation.last + expect(generation.generated_flashcards).to be_an(Array) + expect(generation.generated_flashcards.length).to eq(2) + end + + it "stores generation metadata" do + post generations_path, params: { generation: { source_text: source_text } } + + generation = Generation.last + expect(generation.model).to eq('openai/gpt-4o-mini') + expect(generation.generation_duration).to eq(1500) + expect(generation.generated_count).to eq(2) + end + + it "redirects to review page" do + post generations_path, params: { generation: { source_text: source_text } } + + generation = Generation.last + expect(response).to redirect_to(review_generation_path(generation)) + end + + it "does not create flashcards immediately" do + expect { + post generations_path, params: { generation: { source_text: source_text } } + }.not_to change(Flashcard, :count) + end + end + + context "when source text is too short" do + it "does not create generation" do + expect { + post generations_path, params: { generation: { source_text: 'Too short' } } + }.not_to change(Generation, :count) + end + + it "renders new template with errors" do + post generations_path, params: { generation: { source_text: 'Too short' } } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('errors') + end + end + + context "when source text is too long" do + let(:long_text) { 'A' * 10_001 } + + it "does not create generation" do + expect { + post generations_path, params: { generation: { source_text: long_text } } + }.not_to change(Generation, :count) + end + + it "renders new template with errors" do + post generations_path, params: { generation: { source_text: long_text } } + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context "when OpenRouter returns rate limit error" do + before do + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::RateLimitError, 'Rate limit exceeded') + end + + it "does not create generation" do + expect { + post generations_path, params: { generation: { source_text: source_text } } + }.not_to change(Generation, :count) + end + + it "renders new template with rate limit message" do + post generations_path, params: { generation: { source_text: source_text } } + + expect(response).to have_http_status(:too_many_requests) + expect(response.body).to include('Too many requests') + end + end + + context "when OpenRouter returns insufficient credits error" do + before do + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::InsufficientCreditsError, 'Insufficient credits') + end + + it "renders new template with service unavailable message" do + post generations_path, params: { generation: { source_text: source_text } } + + expect(response).to have_http_status(:service_unavailable) + expect(response.body).to include('Service temporarily unavailable') + end + end + + context "when OpenRouter returns network error" do + before do + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::NetworkError, 'Network timeout') + end + + it "renders new template with timeout message" do + post generations_path, params: { generation: { source_text: source_text } } + + expect(response).to have_http_status(:request_timeout) + expect(response.body).to include('Connection timeout') + end + end + + context "when OpenRouter returns authentication error" do + before do + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::AuthenticationError, 'Invalid API key') + end + + it "renders new template with configuration error message" do + post generations_path, params: { generation: { source_text: source_text } } + + expect(response).to have_http_status(:service_unavailable) + expect(response.body).to include('Service configuration error') + end + + it "logs error message" do + expect(Rails.logger).to receive(:error).with(/authentication failed/) + + post generations_path, params: { generation: { source_text: source_text } } + end + end + + context "when OpenRouter returns general API error" do + before do + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::APIError, 'API error') + end + + it "renders new template with generic error message" do + post generations_path, params: { generation: { source_text: source_text } } + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('Failed to generate flashcards') + end + + it "logs error details" do + expect(Rails.logger).to receive(:error).with(/API error/) + expect(Rails.logger).to receive(:error).with(String) # backtrace + + post generations_path, params: { generation: { source_text: source_text } } + end + end + + context "when generation save fails" do + before do + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'Q1', back: 'A1' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1000, + generated_count: 1 + } + }) + + # Force generation save to fail + allow_any_instance_of(Generation).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(Generation.new)) + end + + it "does not create generation" do + expect { + post generations_path, params: { generation: { source_text: source_text } } + }.not_to change(Generation, :count) + end + + it "renders new template with error" do + post generations_path, params: { generation: { source_text: source_text } } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + context "when user is not authenticated" do + before do + sign_out user + end + + it "redirects to sign in page for index" do + get generations_path + expect(response).to redirect_to(new_user_session_path) + end + + it "redirects to sign in page for show" do + generation = create(:generation, user: user, source_text: valid_source_text) + get generation_path(generation) + expect(response).to redirect_to(new_user_session_path) + end + + it "redirects to sign in page for new" do + get new_generation_path + expect(response).to redirect_to(new_user_session_path) + end + + it "redirects to sign in page for create" do + post generations_path, params: { generation: { source_text: valid_source_text } } + expect(response).to redirect_to(new_user_session_path) + end end end diff --git a/spec/services/flashcard_generation_service_spec.rb b/spec/services/flashcard_generation_service_spec.rb new file mode 100644 index 0000000..6afec07 --- /dev/null +++ b/spec/services/flashcard_generation_service_spec.rb @@ -0,0 +1,179 @@ +require 'rails_helper' + +RSpec.describe FlashcardGenerationService do + describe '#generate' do + let(:service) { described_class.new } + let(:source_text) do + <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + end + + context 'when generation is successful' do + it 'returns flashcards data with metadata' do + mock_response = { + 'flashcards' => [ + { 'question' => 'What is Ruby on Rails?', 'answer' => 'A web application framework' }, + { 'question' => 'What pattern does Rails follow?', 'answer' => 'MVC pattern' } + ] + } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + result = service.generate(source_text) + + expect(result).to be_a(Hash) + expect(result).to have_key(:flashcards) + expect(result).to have_key(:metadata) + + flashcards = result[:flashcards] + expect(flashcards).to be_an(Array) + expect(flashcards.length).to eq(2) + expect(flashcards.first).to have_key(:front) + expect(flashcards.first).to have_key(:back) + + metadata = result[:metadata] + expect(metadata).to have_key(:model) + expect(metadata).to have_key(:generation_duration) + expect(metadata).to have_key(:generated_count) + expect(metadata[:model]).to eq('openai/gpt-4o-mini') + expect(metadata[:generated_count]).to eq(2) + expect(metadata[:generation_duration]).to be >= 0 + end + + it 'strips whitespace from questions and answers' do + mock_response = { + 'flashcards' => [ + { 'question' => ' What is Rails? ', 'answer' => ' A framework ' } + ] + } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + result = service.generate(source_text) + + flashcards = result[:flashcards] + expect(flashcards.first[:front]).to eq('What is Rails?') + expect(flashcards.first[:back]).to eq('A framework') + end + end + + context 'when source text is blank' do + it 'raises ArgumentError' do + expect { + service.generate('') + }.to raise_error(ArgumentError, 'Source text cannot be blank') + end + end + + context 'when response format is invalid' do + it 'raises ResponseParseError when flashcards key is missing' do + mock_response = { 'data' => [] } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::ResponseParseError, /Invalid flashcards format/) + end + + it 'raises ResponseParseError when flashcards array is empty' do + mock_response = { 'flashcards' => [] } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::ResponseParseError, /Invalid flashcards format|No flashcards generated/) + end + end + + context 'when flashcard validation fails' do + it 'raises error for blank question' do + mock_response = { + 'flashcards' => [ + { 'question' => '', 'answer' => 'Some answer' } + ] + } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::ResponseParseError, /question cannot be blank/) + end + + it 'raises error for blank answer' do + mock_response = { + 'flashcards' => [ + { 'question' => 'What is Rails?', 'answer' => '' } + ] + } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::ResponseParseError, /answer cannot be blank/) + end + + it 'raises error for question too long' do + mock_response = { + 'flashcards' => [ + { 'question' => 'a' * 201, 'answer' => 'Answer' } + ] + } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::ResponseParseError, /question too long/) + end + + it 'raises error for answer too long' do + mock_response = { + 'flashcards' => [ + { 'question' => 'Question?', 'answer' => 'a' * 501 } + ] + } + + allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::ResponseParseError, /answer too long/) + end + end + + context 'when OpenRouter API fails' do + it 'propagates OpenRouterService errors' do + allow_any_instance_of(OpenRouterService).to receive(:complete) + .and_raise(OpenRouterService::RateLimitError, 'Rate limit exceeded') + + expect { + service.generate(source_text) + }.to raise_error(OpenRouterService::RateLimitError, 'Rate limit exceeded') + end + end + end + + describe '#initialize' do + it 'accepts custom model' do + service = described_class.new(model: 'openai/gpt-4o') + expect(service.instance_variable_get(:@model)).to eq('openai/gpt-4o') + end + + it 'accepts custom temperature' do + service = described_class.new(temperature: 0.5) + expect(service.instance_variable_get(:@temperature)).to eq(0.5) + end + + it 'uses default model when not specified' do + service = described_class.new + expect(service.instance_variable_get(:@model)).to eq('openai/gpt-4o-mini') + end + end +end diff --git a/spec/services/open_router_service_spec.rb b/spec/services/open_router_service_spec.rb new file mode 100644 index 0000000..a183d0d --- /dev/null +++ b/spec/services/open_router_service_spec.rb @@ -0,0 +1,348 @@ +require 'rails_helper' + +RSpec.describe OpenRouterService do + let(:api_key) { 'sk-or-v1-test-key-1234567890' } + let(:model) { 'openai/gpt-4o-mini' } + + describe '#initialize' do + context 'when API key is provided' do + it 'initializes successfully with api_key parameter' do + service = described_class.new(model: model, api_key: api_key) + + expect(service.model).to eq(model) + expect(service.temperature).to eq(0.7) + expect(service.max_tokens).to eq(2000) + end + end + + context 'when API key is in environment' do + before do + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(api_key) + end + + it 'uses API key from environment variable' do + service = described_class.new(model: model) + expect(service.send(:api_key)).to eq(api_key) + end + end + + context 'when API key is in Rails credentials' do + before do + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(nil) + allow(Rails.application.credentials).to receive(:dig) + .with(:openrouter, :api_key).and_return(api_key) + end + + it 'uses API key from credentials' do + service = described_class.new(model: model) + expect(service.send(:api_key)).to eq(api_key) + end + end + + context 'when API key is missing' do + before do + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(nil) + allow(Rails.application.credentials).to receive(:dig) + .with(:openrouter, :api_key).and_return(nil) + end + + it 'raises ConfigurationError' do + expect { + described_class.new(model: model) + }.to raise_error( + OpenRouterService::ConfigurationError, + /API key is missing/ + ) + end + end + + context 'when model is blank' do + it 'raises ConfigurationError' do + expect { + described_class.new(model: '', api_key: api_key) + }.to raise_error( + OpenRouterService::ConfigurationError, + 'Model name is required.' + ) + end + end + + context 'with custom parameters' do + it 'accepts custom temperature' do + service = described_class.new( + model: model, + api_key: api_key, + temperature: 0.3 + ) + + expect(service.temperature).to eq(0.3) + end + + it 'accepts custom max_tokens' do + service = described_class.new( + model: model, + api_key: api_key, + max_tokens: 4000 + ) + + expect(service.max_tokens).to eq(4000) + end + + it 'accepts skip_ssl_verify parameter' do + service = described_class.new( + model: model, + api_key: api_key, + skip_ssl_verify: true + ) + + expect(service.instance_variable_get(:@skip_ssl_verify)).to be true + end + end + end + + describe '#complete' do + let(:service) { described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) } + let(:system_message) { 'You are a helpful assistant.' } + let(:user_message) { 'Hello, world!' } + + context 'when request is successful' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .with( + body: hash_including( + model: model, + messages: [ + { role: 'system', content: system_message }, + { role: 'user', content: user_message } + ] + ), + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + } + ) + .to_return( + status: 200, + body: { + choices: [ + { message: { content: 'Hello! How can I help you?' } } + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns parsed response' do + response = service.complete( + system_message: system_message, + user_message: user_message + ) + + expect(response).to eq({ 'content' => 'Hello! How can I help you?' }) + end + end + + context 'when using structured output' do + let(:response_format) do + { + type: 'json_schema', + json_schema: { + name: 'test_schema', + strict: true, + schema: { + type: 'object', + properties: { + message: { type: 'string' } + }, + required: [ 'message' ], + additionalProperties: false + } + } + } + end + + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 200, + body: { + choices: [ + { message: { content: '{"message":"Hello! How can I help you?"}' } } + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns structured response' do + response = service.complete( + system_message: system_message, + user_message: user_message, + response_format: response_format + ) + + expect(response).to eq({ 'message' => 'Hello! How can I help you?' }) + end + end + + context 'when API returns authentication error' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 401, + body: { error: { message: 'Invalid API key' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises AuthenticationError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error( + OpenRouterService::AuthenticationError, + /Invalid API key/ + ) + end + end + + context 'when API returns rate limit error' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 429, + body: { error: { message: 'Rate limit exceeded' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises RateLimitError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::RateLimitError, /Rate limit exceeded/) + end + end + + context 'when API returns insufficient credits error' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 402, + body: { error: { message: 'Insufficient credits' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises InsufficientCreditsError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error( + OpenRouterService::InsufficientCreditsError, + /Insufficient credits/ + ) + end + end + + context 'when API returns bad request error' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 400, + body: { error: { message: 'Invalid request' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises InvalidRequestError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::InvalidRequestError, /Invalid request/) + end + end + + context 'when API returns server error' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 500, + body: { error: { message: 'Internal server error' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ServerError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::ServerError, /server error/) + end + end + + context 'when response contains error in body (HTTP 200)' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 200, + body: { + error: { + code: 'provider_error', + message: 'Provider returned error' + } + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises APIError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::APIError, /Provider returned error/) + end + end + + context 'when network timeout occurs' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_timeout + end + + it 'raises NetworkError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::NetworkError, /timeout/) + end + end + + context 'when response is invalid JSON' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 200, + body: 'invalid json', + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ResponseParseError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::ResponseParseError) + end + end + + context 'when response is missing content' do + before do + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') + .to_return( + status: 200, + body: { choices: [] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ResponseParseError' do + expect { + service.complete(system_message: system_message, user_message: user_message) + }.to raise_error(OpenRouterService::ResponseParseError, /No content/) + end + end + end +end diff --git a/spec/system/flashcards_spec.rb b/spec/system/flashcards_spec.rb index 38a7b59..04c1ee6 100644 --- a/spec/system/flashcards_spec.rb +++ b/spec/system/flashcards_spec.rb @@ -434,7 +434,7 @@ expect(flashcard.front).to eq('Original question') end - it "preserves source type when updating flashcard" do + it "changes source to ai_edited when updating AI-generated flashcard" do user = create(:user, email: 'user@example.com', password: 'password123') flashcard = create(:flashcard, :ai_full, user: user, front: 'AI question', back: 'AI answer') @@ -450,7 +450,7 @@ flashcard.reload expect(flashcard.front).to eq('Edited AI question') - expect(flashcard.source).to eq('ai_full') + expect(flashcard.source).to eq('ai_edited') end it "displays success message after updating flashcard" do From 1729b8ce5ab2bac91f32852b2dc50315574b8ab9 Mon Sep 17 00:00:00 2001 From: Monika Dubel Date: Tue, 27 Jan 2026 18:30:07 +0100 Subject: [PATCH 2/4] fix rspec --- spec/requests/generations_spec.rb | 231 ++++++++++++++++++++++++------ 1 file changed, 189 insertions(+), 42 deletions(-) diff --git a/spec/requests/generations_spec.rb b/spec/requests/generations_spec.rb index f258ca1..ed2ee8a 100644 --- a/spec/requests/generations_spec.rb +++ b/spec/requests/generations_spec.rb @@ -1,20 +1,20 @@ require 'rails_helper' RSpec.describe "Generations", type: :request do - let(:user) { create(:user) } - let(:valid_source_text) { 'A' * 1500 } # Minimum 1000 characters - - before do - sign_in user - end - describe "GET /generations" do it "returns http success" do + user = create(:user) + sign_in user + get generations_path expect(response).to have_http_status(:success) end it "displays user's generations" do + user = create(:user) + valid_source_text = 'A' * 1500 + sign_in user + generation1 = create(:generation, user: user, source_text: valid_source_text) generation2 = create(:generation, user: user, source_text: valid_source_text) @@ -25,6 +25,10 @@ end it "does not display other users' generations" do + user = create(:user) + valid_source_text = 'A' * 1500 + sign_in user + other_user = create(:user) other_generation = create(:generation, user: other_user, source_text: valid_source_text) @@ -35,23 +39,37 @@ end describe "GET /generations/:id" do - let(:generation) { create(:generation, user: user, source_text: valid_source_text) } - it "returns http success" do + user = create(:user) + valid_source_text = 'A' * 1500 + sign_in user + + generation = create(:generation, user: user, source_text: valid_source_text) + get generation_path(generation) expect(response).to have_http_status(:success) end it "displays generation details" do + user = create(:user) + valid_source_text = 'A' * 1500 + sign_in user + + generation = create(:generation, user: user, source_text: valid_source_text) + get generation_path(generation) expect(response.body).to include(generation.source_text[0..50]) end context "when generation belongs to another user" do - let(:other_user) { create(:user) } - let(:other_generation) { create(:generation, user: other_user, source_text: valid_source_text) } - it "renders 404 page when trying to access another user's generation" do + user = create(:user) + valid_source_text = 'A' * 1500 + sign_in user + + other_user = create(:user) + other_generation = create(:generation, user: other_user, source_text: valid_source_text) + get generation_path(other_generation) expect(response).to have_http_status(:not_found) expect(response.body).to include('Not Found') @@ -61,17 +79,21 @@ describe "GET /generations/new" do it "returns http success" do + user = create(:user) + sign_in user + get new_generation_path expect(response).to have_http_status(:success) end end describe "POST /generations" do - let(:source_text) { 'Rails is a web framework. ' * 100 } # ~2700 chars - context "when generation is successful" do - before do - # Mock FlashcardGenerationService with new response format + it "creates a new generation" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ flashcards: [ { front: 'What is Rails?', back: 'A web framework' }, @@ -83,15 +105,29 @@ generated_count: 2 } }) - end - it "creates a new generation" do expect { post generations_path, params: { generation: { source_text: source_text } } }.to change(Generation, :count).by(1) end it "stores generated flashcards data" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'What is Rails?', back: 'A web framework' }, + { front: 'What language is Rails written in?', back: 'Ruby' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1500, + generated_count: 2 + } + }) + post generations_path, params: { generation: { source_text: source_text } } generation = Generation.last @@ -100,6 +136,22 @@ end it "stores generation metadata" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'What is Rails?', back: 'A web framework' }, + { front: 'What language is Rails written in?', back: 'Ruby' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1500, + generated_count: 2 + } + }) + post generations_path, params: { generation: { source_text: source_text } } generation = Generation.last @@ -109,6 +161,22 @@ end it "redirects to review page" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'What is Rails?', back: 'A web framework' }, + { front: 'What language is Rails written in?', back: 'Ruby' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1500, + generated_count: 2 + } + }) + post generations_path, params: { generation: { source_text: source_text } } generation = Generation.last @@ -116,6 +184,22 @@ end it "does not create flashcards immediately" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'What is Rails?', back: 'A web framework' }, + { front: 'What language is Rails written in?', back: 'Ruby' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1500, + generated_count: 2 + } + }) + expect { post generations_path, params: { generation: { source_text: source_text } } }.not_to change(Flashcard, :count) @@ -124,12 +208,18 @@ context "when source text is too short" do it "does not create generation" do + user = create(:user) + sign_in user + expect { post generations_path, params: { generation: { source_text: 'Too short' } } }.not_to change(Generation, :count) end it "renders new template with errors" do + user = create(:user) + sign_in user + post generations_path, params: { generation: { source_text: 'Too short' } } expect(response).to have_http_status(:unprocessable_entity) @@ -138,15 +228,21 @@ end context "when source text is too long" do - let(:long_text) { 'A' * 10_001 } - it "does not create generation" do + user = create(:user) + long_text = 'A' * 10_001 + sign_in user + expect { post generations_path, params: { generation: { source_text: long_text } } }.not_to change(Generation, :count) end it "renders new template with errors" do + user = create(:user) + long_text = 'A' * 10_001 + sign_in user + post generations_path, params: { generation: { source_text: long_text } } expect(response).to have_http_status(:unprocessable_entity) @@ -154,18 +250,27 @@ end context "when OpenRouter returns rate limit error" do - before do + it "does not create generation" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) .and_raise(OpenRouterService::RateLimitError, 'Rate limit exceeded') - end - it "does not create generation" do expect { post generations_path, params: { generation: { source_text: source_text } } }.not_to change(Generation, :count) end it "renders new template with rate limit message" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::RateLimitError, 'Rate limit exceeded') + post generations_path, params: { generation: { source_text: source_text } } expect(response).to have_http_status(:too_many_requests) @@ -174,12 +279,14 @@ end context "when OpenRouter returns insufficient credits error" do - before do + it "renders new template with service unavailable message" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) .and_raise(OpenRouterService::InsufficientCreditsError, 'Insufficient credits') - end - it "renders new template with service unavailable message" do post generations_path, params: { generation: { source_text: source_text } } expect(response).to have_http_status(:service_unavailable) @@ -188,12 +295,14 @@ end context "when OpenRouter returns network error" do - before do + it "renders new template with timeout message" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) .and_raise(OpenRouterService::NetworkError, 'Network timeout') - end - it "renders new template with timeout message" do post generations_path, params: { generation: { source_text: source_text } } expect(response).to have_http_status(:request_timeout) @@ -202,12 +311,14 @@ end context "when OpenRouter returns authentication error" do - before do + it "renders new template with configuration error message" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) .and_raise(OpenRouterService::AuthenticationError, 'Invalid API key') - end - it "renders new template with configuration error message" do post generations_path, params: { generation: { source_text: source_text } } expect(response).to have_http_status(:service_unavailable) @@ -215,6 +326,13 @@ end it "logs error message" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::AuthenticationError, 'Invalid API key') + expect(Rails.logger).to receive(:error).with(/authentication failed/) post generations_path, params: { generation: { source_text: source_text } } @@ -222,12 +340,14 @@ end context "when OpenRouter returns general API error" do - before do + it "renders new template with generic error message" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) .and_raise(OpenRouterService::APIError, 'API error') - end - it "renders new template with generic error message" do post generations_path, params: { generation: { source_text: source_text } } expect(response).to have_http_status(:unprocessable_entity) @@ -235,6 +355,13 @@ end it "logs error details" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate) + .and_raise(OpenRouterService::APIError, 'API error') + expect(Rails.logger).to receive(:error).with(/API error/) expect(Rails.logger).to receive(:error).with(String) # backtrace @@ -243,7 +370,11 @@ end context "when generation save fails" do - before do + it "does not create generation" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ flashcards: [ { front: 'Q1', back: 'A1' } @@ -257,15 +388,31 @@ # Force generation save to fail allow_any_instance_of(Generation).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(Generation.new)) - end - it "does not create generation" do expect { post generations_path, params: { generation: { source_text: source_text } } }.not_to change(Generation, :count) end it "renders new template with error" do + user = create(:user) + source_text = 'Rails is a web framework. ' * 100 + sign_in user + + allow_any_instance_of(FlashcardGenerationService).to receive(:generate).and_return({ + flashcards: [ + { front: 'Q1', back: 'A1' } + ], + metadata: { + model: 'openai/gpt-4o-mini', + generation_duration: 1000, + generated_count: 1 + } + }) + + # Force generation save to fail + allow_any_instance_of(Generation).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(Generation.new)) + post generations_path, params: { generation: { source_text: source_text } } expect(response).to have_http_status(:unprocessable_entity) end @@ -273,17 +420,16 @@ end context "when user is not authenticated" do - before do - sign_out user - end - it "redirects to sign in page for index" do get generations_path expect(response).to redirect_to(new_user_session_path) end it "redirects to sign in page for show" do + user = create(:user) + valid_source_text = 'A' * 1500 generation = create(:generation, user: user, source_text: valid_source_text) + get generation_path(generation) expect(response).to redirect_to(new_user_session_path) end @@ -294,6 +440,7 @@ end it "redirects to sign in page for create" do + valid_source_text = 'A' * 1500 post generations_path, params: { generation: { source_text: valid_source_text } } expect(response).to redirect_to(new_user_session_path) end From bd771357ab7e8e342f9506069121bb3d99b9da70 Mon Sep 17 00:00:00 2001 From: Monika Dubel Date: Tue, 27 Jan 2026 18:37:01 +0100 Subject: [PATCH 3/4] fix rspec --- .../flashcard_generation_service_spec.rb | 126 ++++++++++++-- spec/services/open_router_service_spec.rb | 163 ++++++++++++------ 2 files changed, 219 insertions(+), 70 deletions(-) diff --git a/spec/services/flashcard_generation_service_spec.rb b/spec/services/flashcard_generation_service_spec.rb index 6afec07..cb78ea4 100644 --- a/spec/services/flashcard_generation_service_spec.rb +++ b/spec/services/flashcard_generation_service_spec.rb @@ -2,17 +2,14 @@ RSpec.describe FlashcardGenerationService do describe '#generate' do - let(:service) { described_class.new } - let(:source_text) do - <<~TEXT - Ruby on Rails is a web application framework written in Ruby. - It follows the Model-View-Controller (MVC) architectural pattern. - Rails emphasizes convention over configuration and the DRY principle. - TEXT - end - context 'when generation is successful' do it 'returns flashcards data with metadata' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [ { 'question' => 'What is Ruby on Rails?', 'answer' => 'A web application framework' }, @@ -20,8 +17,12 @@ ] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + service = described_class.new result = service.generate(source_text) expect(result).to be_a(Hash) @@ -44,14 +45,24 @@ end it 'strips whitespace from questions and answers' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [ { 'question' => ' What is Rails? ', 'answer' => ' A framework ' } ] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + service = described_class.new result = service.generate(source_text) flashcards = result[:flashcards] @@ -62,6 +73,8 @@ context 'when source text is blank' do it 'raises ArgumentError' do + service = described_class.new + expect { service.generate('') }.to raise_error(ArgumentError, 'Source text cannot be blank') @@ -70,9 +83,20 @@ context 'when response format is invalid' do it 'raises ResponseParseError when flashcards key is missing' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'data' => [] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + + service = described_class.new expect { service.generate(source_text) @@ -80,9 +104,20 @@ end it 'raises ResponseParseError when flashcards array is empty' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + + service = described_class.new expect { service.generate(source_text) @@ -92,13 +127,24 @@ context 'when flashcard validation fails' do it 'raises error for blank question' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [ { 'question' => '', 'answer' => 'Some answer' } ] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + + service = described_class.new expect { service.generate(source_text) @@ -106,13 +152,24 @@ end it 'raises error for blank answer' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [ { 'question' => 'What is Rails?', 'answer' => '' } ] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + + service = described_class.new expect { service.generate(source_text) @@ -120,13 +177,24 @@ end it 'raises error for question too long' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [ { 'question' => 'a' * 201, 'answer' => 'Answer' } ] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + + service = described_class.new expect { service.generate(source_text) @@ -134,13 +202,24 @@ end it 'raises error for answer too long' do + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + mock_response = { 'flashcards' => [ { 'question' => 'Question?', 'answer' => 'a' * 501 } ] } - allow_any_instance_of(OpenRouterService).to receive(:complete).and_return(mock_response) + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete).and_return(mock_response) + + service = described_class.new expect { service.generate(source_text) @@ -150,9 +229,20 @@ context 'when OpenRouter API fails' do it 'propagates OpenRouterService errors' do - allow_any_instance_of(OpenRouterService).to receive(:complete) + source_text = <<~TEXT + Ruby on Rails is a web application framework written in Ruby. + It follows the Model-View-Controller (MVC) architectural pattern. + Rails emphasizes convention over configuration and the DRY principle. + TEXT + + # Mock OpenRouterService initialization to avoid API key requirement + mock_openrouter = instance_double(OpenRouterService) + allow(OpenRouterService).to receive(:new).and_return(mock_openrouter) + allow(mock_openrouter).to receive(:complete) .and_raise(OpenRouterService::RateLimitError, 'Rate limit exceeded') + service = described_class.new + expect { service.generate(source_text) }.to raise_error(OpenRouterService::RateLimitError, 'Rate limit exceeded') diff --git a/spec/services/open_router_service_spec.rb b/spec/services/open_router_service_spec.rb index a183d0d..202772b 100644 --- a/spec/services/open_router_service_spec.rb +++ b/spec/services/open_router_service_spec.rb @@ -1,12 +1,12 @@ require 'rails_helper' RSpec.describe OpenRouterService do - let(:api_key) { 'sk-or-v1-test-key-1234567890' } - let(:model) { 'openai/gpt-4o-mini' } - describe '#initialize' do context 'when API key is provided' do it 'initializes successfully with api_key parameter' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + service = described_class.new(model: model, api_key: api_key) expect(service.model).to eq(model) @@ -16,37 +16,39 @@ end context 'when API key is in environment' do - before do + it 'uses API key from environment variable' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(api_key) - end - it 'uses API key from environment variable' do service = described_class.new(model: model) expect(service.send(:api_key)).to eq(api_key) end end context 'when API key is in Rails credentials' do - before do + it 'uses API key from credentials' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(nil) allow(Rails.application.credentials).to receive(:dig) .with(:openrouter, :api_key).and_return(api_key) - end - it 'uses API key from credentials' do service = described_class.new(model: model) expect(service.send(:api_key)).to eq(api_key) end end context 'when API key is missing' do - before do + it 'raises ConfigurationError' do + model = 'openai/gpt-4o-mini' + allow(ENV).to receive(:[]).with('OPENROUTER_API_KEY').and_return(nil) allow(Rails.application.credentials).to receive(:dig) .with(:openrouter, :api_key).and_return(nil) - end - it 'raises ConfigurationError' do expect { described_class.new(model: model) }.to raise_error( @@ -58,6 +60,8 @@ context 'when model is blank' do it 'raises ConfigurationError' do + api_key = 'sk-or-v1-test-key-1234567890' + expect { described_class.new(model: '', api_key: api_key) }.to raise_error( @@ -69,6 +73,9 @@ context 'with custom parameters' do it 'accepts custom temperature' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + service = described_class.new( model: model, api_key: api_key, @@ -79,6 +86,9 @@ end it 'accepts custom max_tokens' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + service = described_class.new( model: model, api_key: api_key, @@ -89,6 +99,9 @@ end it 'accepts skip_ssl_verify parameter' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + service = described_class.new( model: model, api_key: api_key, @@ -101,12 +114,13 @@ end describe '#complete' do - let(:service) { described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) } - let(:system_message) { 'You are a helpful assistant.' } - let(:user_message) { 'Hello, world!' } - context 'when request is successful' do - before do + it 'returns parsed response' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .with( body: hash_including( @@ -130,9 +144,8 @@ }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'returns parsed response' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) response = service.complete( system_message: system_message, user_message: user_message @@ -143,8 +156,12 @@ end context 'when using structured output' do - let(:response_format) do - { + it 'returns structured response' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + response_format = { type: 'json_schema', json_schema: { name: 'test_schema', @@ -159,9 +176,7 @@ } } } - end - before do stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 200, @@ -172,9 +187,8 @@ }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'returns structured response' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) response = service.complete( system_message: system_message, user_message: user_message, @@ -186,16 +200,21 @@ end context 'when API returns authentication error' do - before do + it 'raises AuthenticationError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 401, body: { error: { message: 'Invalid API key' } }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises AuthenticationError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error( @@ -206,16 +225,21 @@ end context 'when API returns rate limit error' do - before do + it 'raises RateLimitError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 429, body: { error: { message: 'Rate limit exceeded' } }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises RateLimitError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::RateLimitError, /Rate limit exceeded/) @@ -223,16 +247,21 @@ end context 'when API returns insufficient credits error' do - before do + it 'raises InsufficientCreditsError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 402, body: { error: { message: 'Insufficient credits' } }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises InsufficientCreditsError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error( @@ -243,16 +272,21 @@ end context 'when API returns bad request error' do - before do + it 'raises InvalidRequestError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 400, body: { error: { message: 'Invalid request' } }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises InvalidRequestError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::InvalidRequestError, /Invalid request/) @@ -260,16 +294,21 @@ end context 'when API returns server error' do - before do + it 'raises ServerError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 500, body: { error: { message: 'Internal server error' } }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises ServerError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::ServerError, /server error/) @@ -277,7 +316,12 @@ end context 'when response contains error in body (HTTP 200)' do - before do + it 'raises APIError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 200, @@ -289,9 +333,9 @@ }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises APIError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::APIError, /Provider returned error/) @@ -299,12 +343,17 @@ end context 'when network timeout occurs' do - before do + it 'raises NetworkError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_timeout - end - it 'raises NetworkError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::NetworkError, /timeout/) @@ -312,16 +361,21 @@ end context 'when response is invalid JSON' do - before do + it 'raises ResponseParseError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 200, body: 'invalid json', headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises ResponseParseError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::ResponseParseError) @@ -329,16 +383,21 @@ end context 'when response is missing content' do - before do + it 'raises ResponseParseError' do + api_key = 'sk-or-v1-test-key-1234567890' + model = 'openai/gpt-4o-mini' + system_message = 'You are a helpful assistant.' + user_message = 'Hello, world!' + stub_request(:post, 'https://openrouter.ai/api/v1/chat/completions') .to_return( status: 200, body: { choices: [] }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it 'raises ResponseParseError' do + service = described_class.new(model: model, api_key: api_key, skip_ssl_verify: true) + expect { service.complete(system_message: system_message, user_message: user_message) }.to raise_error(OpenRouterService::ResponseParseError, /No content/) From 13fc8aa7cbdc1f67d8ec457950b13948f41c9632 Mon Sep 17 00:00:00 2001 From: Monika Dubel Date: Tue, 27 Jan 2026 18:43:03 +0100 Subject: [PATCH 4/4] fix specs --- spec/requests/generations_spec.rb | 6 ++++-- spec/support/devise.rb | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 spec/support/devise.rb diff --git a/spec/requests/generations_spec.rb b/spec/requests/generations_spec.rb index ed2ee8a..2a7bd38 100644 --- a/spec/requests/generations_spec.rb +++ b/spec/requests/generations_spec.rb @@ -27,14 +27,16 @@ it "does not display other users' generations" do user = create(:user) valid_source_text = 'A' * 1500 + user_generation = create(:generation, user: user, source_text: 'User generation text: ' + valid_source_text) sign_in user other_user = create(:user) - other_generation = create(:generation, user: other_user, source_text: valid_source_text) + other_generation = create(:generation, user: other_user, source_text: 'Other generation text: ' + valid_source_text) get generations_path - expect(response.body).not_to include(other_generation.id.to_s) + expect(response.body).to include('User generation text:') + expect(response.body).not_to include('Other generation text:') end end diff --git a/spec/support/devise.rb b/spec/support/devise.rb new file mode 100644 index 0000000..b35d3d2 --- /dev/null +++ b/spec/support/devise.rb @@ -0,0 +1,9 @@ +RSpec.configure do |config| + config.include Devise::Test::IntegrationHelpers, type: :request + config.include Devise::Test::IntegrationHelpers, type: :system + + # Ensure Devise mappings are loaded + config.before(:suite) do + Rails.application.reload_routes! + end +end