diff --git a/src/openai_utils.py b/src/openai_utils.py index dff845c..b1ae5b0 100644 --- a/src/openai_utils.py +++ b/src/openai_utils.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Optional, Tuple, Dict, Any from openai import AsyncOpenAI # Używamy AsyncOpenAI dla kompatybilności z asyncio from .config import OPENAI_API_KEY, SUMMARY_PROMPT @@ -21,6 +21,10 @@ class SummarizationError(OpenAIUtilsError): """Wyjątek rzucany przy błędach streszczania tekstu.""" pass +class QuotaExceededError(OpenAIUtilsError): + """Wyjątek rzucany gdy przekroczono limit zapytań API.""" + pass + # Inicjalizuj klienta OpenAI asynchronicznie client = None try: @@ -32,6 +36,62 @@ except Exception as e: logger.error(f"Błąd inicjalizacji klienta OpenAI: {e}", exc_info=True) # Nie rzucamy tu wyjątku, bo to moment inicjalizacji modułu +async def check_openai_api_status() -> Tuple[bool, Dict[str, Any]]: + """ + Sprawdza status API OpenAI, w tym dostępne limity użycia. + + Returns: + Krotka (status, info), gdzie status to True, jeśli API jest dostępne + i info to słownik z dodatkowymi informacjami o statusie + + Raises: + APIKeyMissingError: Gdy brak klucza API OpenAI + QuotaExceededError: Gdy przekroczono limit zapytań API + """ + if not client: + logger.error("Klient OpenAI nie został zainicjalizowany.") + raise APIKeyMissingError("Klient OpenAI nie został zainicjalizowany. Sprawdź klucz API.") + + try: + logger.info("Sprawdzanie statusu API OpenAI") + + # Używamy prostego zapytania do sprawdzenia statusu konta + response = await client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": "This is a test message to check API status."}, + {"role": "user", "content": "Hello"} + ], + max_tokens=5 + ) + + # Sprawdzamy czy odpowiedź jest poprawna + if response and response.choices and len(response.choices) > 0: + status_info = { + "available": True, + "model": "gpt-4o-mini", + "usage": vars(response.usage) if hasattr(response, "usage") else {}, + "organization_id": getattr(response, "organization_id", None) + } + logger.info(f"API OpenAI jest dostępne, użycie tokenów: {status_info['usage']}") + return True, status_info + else: + status_info = {"available": False, "error": "Nieprawidłowa odpowiedź API"} + logger.warning("API OpenAI zwróciło nieprawidłową odpowiedź podczas testu") + return False, status_info + + except Exception as e: + error_message = str(e) + status_info = {"available": False, "error": error_message} + + # Sprawdzamy czy to błąd limitu zapytań + if "429" in error_message or "quota" in error_message.lower() or "insufficient_quota" in error_message: + logger.error(f"Przekroczono limit zapytań API OpenAI: {e}") + raise QuotaExceededError(f"Przekroczono limit zapytań API OpenAI: {error_message}") + + logger.error(f"Błąd podczas sprawdzania statusu API OpenAI: {e}", exc_info=True) + return False, status_info + async def summarize_text(text: str) -> str: """ Wysyła tekst do API OpenAI w celu streszczenia. @@ -45,7 +105,8 @@ async def summarize_text(text: str) -> str: Raises: EmptyTextError: Gdy tekst jest pusty APIKeyMissingError: Gdy brak klucza API OpenAI - SummarizationError: Przy błędach API OpenAI + QuotaExceededError: Gdy przekroczono limit zapytań API + SummarizationError: Przy innych błędach API OpenAI """ if not text: logger.warning("Próba streszczenia pustego tekstu.") @@ -75,5 +136,11 @@ async def summarize_text(text: str) -> str: return summary except Exception as e: + error_message = str(e) logger.error(f"Błąd API OpenAI podczas streszczania: {e}", exc_info=True) + + # Sprawdzamy, czy to błąd limitu zapytań + if "429" in error_message or "quota" in error_message.lower() or "insufficient_quota" in error_message: + raise QuotaExceededError(f"Przekroczono limit zapytań API OpenAI: {error_message}") + raise SummarizationError(f"Błąd API OpenAI: {str(e)}") \ No newline at end of file diff --git a/tests/check_env.py b/tests/check_env.py new file mode 100644 index 0000000..414cb02 --- /dev/null +++ b/tests/check_env.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +Prosty skrypt do sprawdzania dostępności zmiennych środowiskowych i API +przed uruchomieniem testów integracyjnych. +""" + +import os +import sys + +def main(): + """Wyświetla status zmiennych środowiskowych i informacje o testach""" + print("\n* Sprawdzanie zmiennych środowiskowych przed uruchomieniem testów:") + + # Sprawdź YOUTUBE_TRANSCRIPT_API_TOKEN + has_youtube_token = bool(os.environ.get("YOUTUBE_TRANSCRIPT_API_TOKEN")) + youtube_status = "Ustawiony" if has_youtube_token else "BRAK - niektóre testy będą pominięte" + print(f" - YOUTUBE_TRANSCRIPT_API_TOKEN: {youtube_status}") + + # Sprawdź OPENAI_API_KEY + has_openai_key = bool(os.environ.get("OPENAI_API_KEY")) + openai_status = "Ustawiony" if has_openai_key else "BRAK - testy OpenAI będą pominięte" + print(f" - OPENAI_API_KEY: {openai_status}") + + print("\n* Informacje o testach:") + print(" - Testy integracyjne z OpenAI API sprawdzą dostępność API przed wykonaniem.") + print(" - Jeżeli limit API OpenAI został przekroczony, testy będą automatycznie pominięte.") + print(" - Testy dla YouTube API będą używać filmy 'What makes a good life?' lub 'Me at the zoo'.") + print() + + if not has_youtube_token and not has_openai_key: + print("UWAGA: Brak kluczowych zmiennych środowiskowych dla testów integracyjnych!") + return 1 + + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tests/test_openai_utils.py b/tests/test_openai_utils.py index 16e4f8d..51631ed 100644 --- a/tests/test_openai_utils.py +++ b/tests/test_openai_utils.py @@ -6,7 +6,14 @@ import os # Dodanie katalogu nadrzędnego do ścieżki dla importów sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from src.openai_utils import summarize_text +from src.openai_utils import ( + summarize_text, + check_openai_api_status, + EmptyTextError, + APIKeyMissingError, + SummarizationError, + QuotaExceededError +) @pytest.mark.asyncio @@ -27,17 +34,17 @@ async def test_summarize_text_success(): "Zawiera kilka zdań o różnej tematyce." # Test funkcji z mockiem - with patch('src.openai_utils.client.chat.completions.create', - new=AsyncMock(return_value=mock_response)) as mock_create: + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) result = await summarize_text(test_transcript) # Sprawdzenie wyników assert result == "To jest przykładowe streszczenie filmu." - assert mock_create.called + assert mock_client.chat.completions.create.called # Sprawdzenie czy parametry zostały przekazane poprawnie - call_args = mock_create.call_args[1] - assert call_args['model'] == "gpt-3.5-turbo" + call_args = mock_client.chat.completions.create.call_args[1] + assert call_args['model'] == "gpt-4o-mini" # Zaktualizowano model assert call_args['temperature'] == 0.5 assert call_args['max_tokens'] == 150 @@ -67,37 +74,66 @@ async def test_summarize_text_with_transcript_from_youtube(): "Przedstawia różne zastosowania AI w codziennym życiu." # Test funkcji - with patch('src.openai_utils.client.chat.completions.create', - new=AsyncMock(return_value=mock_response)) as mock_create: + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) result = await summarize_text(youtube_transcript) # Sprawdzenie wyników assert result == "Film przedstawia dyskusję na temat sztucznej inteligencji i jej zastosowań w życiu codziennym." - assert mock_create.called + assert mock_client.chat.completions.create.called @pytest.mark.asyncio async def test_summarize_empty_text(): # Test funkcji z pustym tekstem with patch('src.openai_utils.client.chat.completions.create') as mock_create: - result = await summarize_text("") + # Powinien rzucić EmptyTextError dla pustego tekstu + with pytest.raises(EmptyTextError): + await summarize_text("") - # Sprawdzenie wyników - assert result is None + # Sprawdzenie czy mock nie został wywołany assert not mock_create.called @pytest.mark.asyncio async def test_summarize_text_api_error(): # Symulacja błędu API - with patch('src.openai_utils.client.chat.completions.create', - new=AsyncMock(side_effect=Exception("API Error"))) as mock_create: - # Test funkcji - result = await summarize_text("Jakiś tekst transkrypcji") + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(side_effect=Exception("API Error")) - # Sprawdzenie wyników - assert result is None - assert mock_create.called + # Test funkcji - powinien rzucić SummarizationError + with pytest.raises(SummarizationError) as excinfo: + await summarize_text("Jakiś tekst transkrypcji") + + # Sprawdzenie czy wiadomość błędu zawiera oryginalne informacje + assert "API Error" in str(excinfo.value) + assert mock_client.chat.completions.create.called + + +@pytest.mark.asyncio +async def test_summarize_text_quota_exceeded_error(): + # Symulacja błędu przekroczenia limitu + error_response = Exception("Error code: 429 - {'error': {'message': 'You exceeded your current quota', 'type': 'insufficient_quota'}}") + + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(side_effect=error_response) + + # Test funkcji - powinien rzucić QuotaExceededError + with pytest.raises(QuotaExceededError) as excinfo: + await summarize_text("Jakiś tekst transkrypcji") + + # Sprawdzenie czy wiadomość błędu zawiera informację o limicie + assert "Przekroczono limit zapytań API OpenAI" in str(excinfo.value) + assert mock_client.chat.completions.create.called + + +@pytest.mark.asyncio +async def test_summarize_text_no_client(): + # Symulacja braku klienta OpenAI + with patch('src.openai_utils.client', None): + # Test funkcji - powinien rzucić APIKeyMissingError + with pytest.raises(APIKeyMissingError): + await summarize_text("Jakiś tekst transkrypcji") @pytest.mark.asyncio @@ -116,20 +152,94 @@ async def test_summarize_text_prompt_format(): test_transcript = "Testowa transkrypcja" # Przygotowanie spy do przechwycenia argumentów - with patch('src.openai_utils.client.chat.completions.create', - new=AsyncMock(return_value=mock_response)) as mock_create: + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + # Test funkcji await summarize_text(test_transcript) # Sprawdzenie czy został wywołany - assert mock_create.called + assert mock_client.chat.completions.create.called # Pobranie argumentów wywołania - call_args = mock_create.call_args[1] + call_args = mock_client.chat.completions.create.call_args[1] messages = call_args['messages'] # Sprawdzenie czy prompt zawiera odpowiedni format user_prompt = messages[1]['content'] assert "Streść poniższy transkrypt filmu z YouTube" in user_prompt assert "Transkrypt:" in user_prompt - assert test_transcript in user_prompt \ No newline at end of file + assert test_transcript in user_prompt + + +@pytest.mark.asyncio +async def test_check_openai_api_status_success(): + # Przygotowanie mocka dla udanej odpowiedzi + mock_choice = MagicMock() + mock_message = MagicMock() + + mock_message.content = "Hello" + mock_choice.message = mock_message + + mock_response = MagicMock() + mock_response.choices = [mock_choice] + mock_response.usage = MagicMock(_asdict=lambda: {"prompt_tokens": 20, "completion_tokens": 5, "total_tokens": 25}) + mock_response.organization_id = "org-123" + + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + # Test funkcji + available, status_info = await check_openai_api_status() + + # Sprawdzenie wyników + assert available == True + assert status_info["available"] == True + assert status_info["model"] == "gpt-4o-mini" + assert status_info["organization_id"] == "org-123" + assert "usage" in status_info + assert status_info["usage"]["total_tokens"] == 25 + assert mock_client.chat.completions.create.called + + +@pytest.mark.asyncio +async def test_check_openai_api_status_quota_exceeded(): + # Symulacja błędu przekroczenia limitu + error_response = Exception("Error code: 429 - {'error': {'message': 'You exceeded your current quota', 'type': 'insufficient_quota'}}") + + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(side_effect=error_response) + + # Test funkcji - powinien rzucić QuotaExceededError + with pytest.raises(QuotaExceededError) as excinfo: + await check_openai_api_status() + + # Sprawdzenie czy wiadomość błędu zawiera informację o limicie + assert "Przekroczono limit zapytań API OpenAI" in str(excinfo.value) + assert mock_client.chat.completions.create.called + + +@pytest.mark.asyncio +async def test_check_openai_api_status_no_client(): + # Symulacja braku klienta OpenAI + with patch('src.openai_utils.client', None): + # Test funkcji - powinien rzucić APIKeyMissingError + with pytest.raises(APIKeyMissingError): + await check_openai_api_status() + + +@pytest.mark.asyncio +async def test_check_openai_api_status_api_error(): + # Symulacja ogólnego błędu API + with patch('src.openai_utils.client') as mock_client: + mock_client.chat.completions.create = AsyncMock(side_effect=Exception("General API Error")) + + # Test funkcji - nie powinien rzucać wyjątku + available, status_info = await check_openai_api_status() + + # Sprawdzenie wyników + assert available == False + assert status_info["available"] == False + assert "error" in status_info + assert "General API Error" in status_info["error"] + assert mock_client.chat.completions.create.called \ No newline at end of file diff --git a/tests/test_real_integration.py b/tests/test_real_integration.py index f7c9575..e0195e1 100644 --- a/tests/test_real_integration.py +++ b/tests/test_real_integration.py @@ -21,7 +21,7 @@ from src.youtube_utils import ( NoTranscriptLanguagesAvailable, YouTubeUtilsError ) -from src.openai_utils import summarize_text +from src.openai_utils import summarize_text, check_openai_api_status, QuotaExceededError # Konfiguracja logowania dla testów logging.basicConfig( @@ -44,10 +44,48 @@ TEST_LANGUAGES = ["pl", "en"] # Preferujemy polską transkrypcję, potem angiel BACKUP_VIDEO_URL = "https://www.youtube.com/watch?v=jNQXAC9IVRw" # Me at the zoo - pierwszy film na YouTube BACKUP_VIDEO_ID = extract_video_id(BACKUP_VIDEO_URL) +# Flaga określająca dostępność API OpenAI, inicjalizowana jako None (sprawdzana przed testami) +OPENAI_API_AVAILABLE = None +OPENAI_STATUS_INFO = {} + # Oznaczamy testy jako "slow", żeby można było je pominąć za pomocą --skip-slow # lub --skip-integration podczas uruchamiania testów pytestmark = [pytest.mark.integration, pytest.mark.slow] +# Helper do sprawdzania dostępności API OpenAI przed testami +async def ensure_openai_api_status(): + """Sprawdza status API OpenAI i ustawia globalną flagę OPENAI_API_AVAILABLE""" + global OPENAI_API_AVAILABLE, OPENAI_STATUS_INFO + + # Jeśli nie ma klucza API, nie ma sensu sprawdzać + if not os.environ.get("OPENAI_API_KEY"): + logger.warning("Brak klucza API OpenAI, pomijam sprawdzanie statusu API") + OPENAI_API_AVAILABLE = False + OPENAI_STATUS_INFO = {"error": "Brak klucza API"} + return False + + # Jeśli już sprawdziliśmy, używamy zapisanego wyniku + if OPENAI_API_AVAILABLE is not None: + return OPENAI_API_AVAILABLE + + try: + logger.info("Sprawdzam dostępność API OpenAI...") + OPENAI_API_AVAILABLE, OPENAI_STATUS_INFO = await check_openai_api_status() + logger.info(f"Status API OpenAI: {'dostępne' if OPENAI_API_AVAILABLE else 'niedostępne'}") + if not OPENAI_API_AVAILABLE: + logger.warning(f"API OpenAI niedostępne: {OPENAI_STATUS_INFO.get('error', 'Nieznany błąd')}") + return OPENAI_API_AVAILABLE + except QuotaExceededError as e: + logger.warning(f"Przekroczono limit zapytań API OpenAI: {e}") + OPENAI_API_AVAILABLE = False + OPENAI_STATUS_INFO = {"error": str(e)} + return False + except Exception as e: + logger.error(f"Błąd podczas sprawdzania statusu API OpenAI: {e}", exc_info=True) + OPENAI_API_AVAILABLE = False + OPENAI_STATUS_INFO = {"error": str(e)} + return False + @pytest.mark.asyncio async def test_real_extract_youtube_urls(): @@ -127,10 +165,13 @@ async def test_real_get_transcript_and_title(): @pytest.mark.asyncio -@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="Wymaga klucza API OpenAI") async def test_real_summarize_text(): # Test rzeczywistego streszczania tekstu z OpenAI API - # Ten test wymaga klucza API OpenAI + # Ten test wymaga klucza API OpenAI i dostępnego API + + # Sprawdź czy API OpenAI jest dostępne + if not await ensure_openai_api_status(): + pytest.skip(f"API OpenAI niedostępne: {OPENAI_STATUS_INFO.get('error', 'Nieznany błąd')}") # Użyjmy krótkiej transkrypcji dla oszczędności tokenów short_transcript = """ @@ -151,19 +192,29 @@ async def test_real_summarize_text(): assert len(summary) > 0 logger.info(f"Wygenerowane streszczenie: {summary}") + except QuotaExceededError as e: + logger.warning(f"Przekroczono limit zapytań API OpenAI: {e}") + pytest.skip(f"Przekroczono limit zapytań API OpenAI: {e}") except Exception as e: logger.error(f"Problem z API OpenAI: {e}", exc_info=True) pytest.skip(f"Problem z API OpenAI: {e}") @pytest.mark.asyncio -@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY") or not os.environ.get("YOUTUBE_TRANSCRIPT_API_TOKEN"), - reason="Wymaga klucza API OpenAI i tokenu API youtube-transcript.io") async def test_end_to_end_integration(): """ Ten test wykonuje pełną integrację od linku YouTube do streszczenia. Wymaga dostępu do internetu, tokenu API youtube-transcript.io oraz klucza API OpenAI. """ + # Sprawdź czy mamy token API youtube-transcript.io + from src.config import YOUTUBE_TRANSCRIPT_API_TOKEN + if not YOUTUBE_TRANSCRIPT_API_TOKEN: + pytest.skip("Brak tokenu API youtube-transcript.io") + + # Sprawdź czy API OpenAI jest dostępne + if not await ensure_openai_api_status(): + pytest.skip(f"API OpenAI niedostępne: {OPENAI_STATUS_INFO.get('error', 'Nieznany błąd')}") + logger.info("Rozpoczynam test integracyjny end-to-end") # Testujemy na klasycznym filmie "Me at the zoo", który jest krótki i ma dostępne transkrypcje @@ -209,8 +260,7 @@ async def test_end_to_end_integration(): logger.error(f"Nie można pobrać transkrypcji dla żadnego z testowych filmów: {e}") pytest.skip(f"Nie można pobrać transkrypcji dla żadnego z testowych filmów: {e}") - # 4. Skrócenie transkrypcji dla oszczędności tokenów (tylko dla testu) - # "Me at the zoo" ma wystarczająco krótką transkrypcję, więc możemy użyć całości + # 4. "Me at the zoo" ma wystarczająco krótką transkrypcję, więc możemy użyć całości logger.info(f"Długość oryginalnej transkrypcji: {len(transcript)} znaków") # 5. Wygenerowanie streszczenia @@ -223,6 +273,9 @@ async def test_end_to_end_integration(): logger.info(f"Wygenerowane streszczenie: {summary}") + except QuotaExceededError as e: + logger.warning(f"Przekroczono limit zapytań API OpenAI: {e}") + pytest.skip(f"Przekroczono limit zapytań API OpenAI: {e}") except Exception as e: logger.error(f"Błąd podczas generowania streszczenia: {e}", exc_info=True) pytest.skip(f"Błąd podczas generowania streszczenia: {e}") @@ -241,13 +294,17 @@ if __name__ == "__main__": # Testy wymagające klucza API OpenAI if os.environ.get("OPENAI_API_KEY"): - try: - asyncio.run(test_real_summarize_text()) - if os.environ.get("YOUTUBE_TRANSCRIPT_API_TOKEN"): - asyncio.run(test_end_to_end_integration()) - else: - logger.warning("Pominięto test end-to-end (brak tokenu API youtube-transcript.io)") - except Exception as e: - logger.error(f"Problemy z OpenAI API: {e}", exc_info=True) + asyncio.run(ensure_openai_api_status()) # Sprawdź status API przed uruchomieniem testów + if OPENAI_API_AVAILABLE: + try: + asyncio.run(test_real_summarize_text()) + if os.environ.get("YOUTUBE_TRANSCRIPT_API_TOKEN"): + asyncio.run(test_end_to_end_integration()) + else: + logger.warning("Pominięto test end-to-end (brak tokenu API youtube-transcript.io)") + except Exception as e: + logger.error(f"Problemy z OpenAI API: {e}", exc_info=True) + else: + logger.warning(f"Pominięto testy OpenAI (API niedostępne: {OPENAI_STATUS_INFO.get('error')})") else: logger.warning("Pominięto testy OpenAI (brak klucza API)") \ No newline at end of file diff --git a/tox.ini b/tox.ini index 8c22804..b44d1a8 100644 --- a/tox.ini +++ b/tox.ini @@ -77,6 +77,8 @@ pass_env = DATABASE_URL YOUTUBE_TRANSCRIPT_API_TOKEN commands = + # Sprawdzenie zmiennych środowiskowych przed uruchomieniem testów + python tests/check_env.py # Uruchamianie testów integracyjnych z rozszerzonym logowaniem python -m pytest tests/test_real_integration.py -v --log-cli-level=DEBUG @@ -104,6 +106,8 @@ pass_env = DATABASE_URL YOUTUBE_TRANSCRIPT_API_TOKEN commands = + # Sprawdzenie zmiennych środowiskowych przed uruchomieniem testów + python tests/check_env.py # Uruchamianie wszystkich testów python -m pytest tests/ --log-cli-level=INFO