Skip to content

Commit cff7a64

Browse files
authored
Merge pull request #199 from ryansurf/feat-refactor
gpt udpates
2 parents 463693e + b8a00bd commit cff7a64

9 files changed

Lines changed: 96 additions & 94 deletions

File tree

src/cli.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ class SurfReport:
2121
"""
2222

2323
def __init__(self) -> None:
24-
gpt_env = settings.GPTSettings()
25-
self.gpt_prompt = gpt_env.GPT_PROMPT
26-
self.gpt_info = (gpt_env.API_KEY, gpt_env.GPT_MODEL)
2724
self.db_handler = self._init_db()
2825

2926
@staticmethod
@@ -60,6 +57,8 @@ def run(
6057
if lat is None or long is None:
6158
lat, long = loc_lat, loc_long
6259

60+
assert lat is not None
61+
assert long is not None
6362
arguments = helper.arguments_dictionary(lat, long, city, args)
6463
ocean_data_dict = api.gather_data(lat, long, arguments)
6564

@@ -74,12 +73,11 @@ def _save_report(self, ocean_data_dict) -> None:
7473
except Exception:
7574
logger.warning("Failed to save report to database.")
7675

77-
def _render_output(self, ocean_data_dict, arguments):
76+
@staticmethod
77+
def _render_output(ocean_data_dict, arguments):
7878
"""Renders JSON or human-readable output based on arguments."""
7979
if not arguments["json_output"]:
80-
response = helper.print_outputs(
81-
ocean_data_dict, arguments, self.gpt_prompt, self.gpt_info
82-
)
80+
response = helper.print_outputs(ocean_data_dict, arguments)
8381
return ocean_data_dict, response
8482
helper.json_output(ocean_data_dict)
8583
return ocean_data_dict

src/gpt.py

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,61 @@
22
GPT Functions stored here
33
"""
44

5+
import io
56
import logging
6-
from abc import ABC
7-
from typing import Any
7+
from contextlib import redirect_stdout
8+
from functools import lru_cache
89

9-
from g4f.client import Client
1010
from openai import OpenAI
1111

12+
from src import settings
13+
1214
logger = logging.getLogger(__name__)
1315

16+
MIN_KEY_LEN = 5
17+
18+
gpt_env = settings.GPTSettings()
19+
1420

15-
class Llm(ABC):
21+
class Llm:
1622
def __init__(self, model):
1723
self.model = model
18-
self.client: Any = None
19-
20-
def call_llm(self, surf_summary, gpt_prompt) -> str | None:
21-
try:
22-
response = self.client.chat.completions.create(
23-
model=self.model,
24-
messages=[
25-
{"role": "user", "content": surf_summary + gpt_prompt}
26-
],
27-
)
28-
return response.choices[0].message.content
29-
except Exception as e:
30-
logger.error("LLM request failed: %s", e)
31-
return "Unable to generate GPT response."
24+
self.prompt = gpt_env.GPT_PROMPT
25+
26+
def call_llm(self, surf_summary) -> str | None:
27+
response = self.client.chat.completions.create(
28+
model=self.model,
29+
messages=[{"role": "user", "content": surf_summary + self.prompt}],
30+
)
31+
return response.choices[0].message.content
3232

3333

3434
class FreeGpt(Llm):
3535
def __init__(self, model):
36+
from g4f.client import Client # noqa: PLC0415
37+
3638
super().__init__(model)
3739
self.client = Client()
3840

41+
def call_llm(self, surf_summary) -> str | None:
42+
with redirect_stdout(io.StringIO()):
43+
return super().call_llm(surf_summary)
44+
3945

4046
class OpenAILlm(Llm):
4147
def __init__(self, api_key, model):
4248
super().__init__(model)
4349
self.client = OpenAI(api_key=api_key)
50+
51+
52+
def _create_llm_client() -> Llm:
53+
gpt_api_key = gpt_env.API_KEY
54+
gpt_model = gpt_env.GPT_MODEL
55+
if not gpt_api_key or len(gpt_api_key) < MIN_KEY_LEN:
56+
return FreeGpt(gpt_model)
57+
return OpenAILlm(api_key=gpt_api_key, model=gpt_model)
58+
59+
60+
@lru_cache(maxsize=1)
61+
def get_llm_client() -> Llm:
62+
return _create_llm_client()

src/helper.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import json
66
import logging
77

8-
from src import api, art, gpt
8+
from src import api, art
9+
from src.gpt import get_llm_client
910

1011
logger = logging.getLogger(__name__)
1112

@@ -294,7 +295,7 @@ def json_output(data_dict, print_output=True):
294295
return data_dict
295296

296297

297-
def print_outputs(ocean_data_dict, arguments, gpt_prompt, gpt_info):
298+
def print_outputs(ocean_data_dict, arguments):
298299
"""
299300
Main printing function; calls all the other printing functions.
300301
"""
@@ -322,7 +323,7 @@ def print_outputs(ocean_data_dict, arguments, gpt_prompt, gpt_info):
322323

323324
gpt_response = None
324325
if arguments["gpt"]:
325-
gpt_response = print_gpt(ocean_data_dict, gpt_prompt, gpt_info)
326+
gpt_response = get_gpt_response(ocean_data_dict)
326327
print(gpt_response)
327328
return gpt_response
328329

@@ -377,7 +378,7 @@ def forecast_to_json(forecast_data, decimal):
377378
return forecasts
378379

379380

380-
def print_gpt(surf_data, gpt_prompt, gpt_info):
381+
def get_gpt_response(surf_data):
381382
"""
382383
Builds a surf summary and returns the GPT response.
383384
"""
@@ -387,8 +388,8 @@ def print_gpt(surf_data, gpt_prompt, gpt_info):
387388
f"swell is {surf_data['Swell Direction']} degrees and the swell "
388389
f"period is {surf_data['Period']} seconds."
389390
)
390-
api_key, gpt_model = gpt_info
391-
MIN_KEY_LEN = 5
392-
if not api_key or len(api_key) < MIN_KEY_LEN:
393-
return gpt.FreeGpt(gpt_model).call_llm(summary, gpt_prompt)
394-
return gpt.OpenAILlm(api_key, gpt_model).call_llm(summary, gpt_prompt)
391+
try:
392+
return get_llm_client().call_llm(summary)
393+
except Exception as e:
394+
logger.error("LLM request failed: %s", e)
395+
return "Unable to generate GPT response."

src/open_meteo.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
def _create_client() -> openmeteo_requests.Client:
77
"""Creates a cached, retry-enabled Open-Meteo API client."""
8+
backend = requests_cache.SQLiteCache(
9+
"/tmp/.cache", use_memory=False, wal=True
10+
)
811
cache_session = requests_cache.CachedSession(
9-
"/tmp/.cache", expire_after=3600
12+
backend=backend, expire_after=3600
1013
)
1114
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
1215
return openmeteo_requests.Client(session=retry_session)

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
Pytest configuration and shared fixtures.
3+
"""
4+
5+
from unittest.mock import MagicMock, patch
6+
7+
# Prevent g4f from loading its C extensions (curl_cffi/eventlet) during tests,
8+
# which cause segfaults when mixed with the test runner's event loop.
9+
patch("g4f.client.Client", MagicMock()).start()

tests/test_cli.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,6 @@ def _mock_run_pipeline(mocker, arguments, ocean_data):
6767
# ---------------------------------------------------------------------------
6868

6969

70-
def test_init_stores_gpt_settings(mocker):
71-
"""SurfReport reads GPT prompt and model from settings on construction."""
72-
_mock_settings(mocker, gpt_prompt="surf report", api_key="", model="gpt-4")
73-
report = SurfReport()
74-
assert report.gpt_prompt == "surf report"
75-
assert report.gpt_info == ("", "gpt-4")
76-
77-
7870
def test_init_db_none_when_uri_empty(mocker):
7971
"""db_handler is None when DB_URI is not configured."""
8072
_mock_settings(mocker, db_uri="")

tests/test_gpt.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from unittest.mock import Mock
66

7+
import pytest
8+
79
from src import gpt
810

911

@@ -24,27 +26,23 @@ def test_free_gpt_returns_model_content(mocker):
2426
mock_client.chat.completions.create.return_value = _make_chat_response(
2527
"Great surf day!"
2628
)
27-
mocker.patch("src.gpt.Client", return_value=mock_client)
29+
mocker.patch("g4f.client.Client", return_value=mock_client)
2830

29-
result = gpt.FreeGpt("gpt-3.5-turbo").call_llm(
30-
"surf is 4ft", "what board should I ride?"
31-
)
31+
result = gpt.FreeGpt("gpt-3.5-turbo").call_llm("surf is 4ft")
3232

3333
assert result == "Great surf day!"
3434
mock_client.chat.completions.create.assert_called_once()
3535

3636

37-
def test_free_gpt_returns_fallback_on_exception(mocker):
38-
"""FreeGpt.call_llm returns the error string when the client raises."""
37+
def test_free_gpt_raises_on_exception(mocker):
38+
"""FreeGpt.call_llm propagates exceptions to the caller."""
3939
mock_client = Mock()
4040
mock_client.chat.completions.create.side_effect = Exception("API down")
41-
mocker.patch("src.gpt.Client", return_value=mock_client)
42-
43-
result = gpt.FreeGpt("gpt-3.5-turbo").call_llm(
44-
"surf is 4ft", "what board?"
45-
)
41+
mocker.patch("g4f.client.Client", return_value=mock_client)
4642

47-
assert result == "Unable to generate GPT response."
43+
llm = gpt.FreeGpt("gpt-3.5-turbo")
44+
with pytest.raises(Exception, match="API down"):
45+
llm.call_llm("surf is 4ft")
4846

4947

5048
def test_openai_llm_returns_model_content(mocker):
@@ -55,24 +53,20 @@ def test_openai_llm_returns_model_content(mocker):
5553
)
5654
mocker.patch("src.gpt.OpenAI", return_value=mock_client)
5755

58-
result = gpt.OpenAILlm("sk-testkey", "gpt-4").call_llm(
59-
"surf is 2ft", "recommend a board"
60-
)
56+
result = gpt.OpenAILlm("sk-testkey", "gpt-4").call_llm("surf is 2ft")
6157

6258
assert result == "Bring your longboard."
6359
mock_client.chat.completions.create.assert_called_once()
6460

6561

66-
def test_openai_llm_returns_fallback_on_exception(mocker):
67-
"""OpenAILlm.call_llm returns the error string when the client raises."""
62+
def test_openai_llm_raises_on_exception(mocker):
63+
"""OpenAILlm.call_llm propagates exceptions to the caller."""
6864
mock_client = Mock()
6965
mock_client.chat.completions.create.side_effect = Exception(
7066
"quota exceeded"
7167
)
7268
mocker.patch("src.gpt.OpenAI", return_value=mock_client)
7369

74-
result = gpt.OpenAILlm("sk-key", "gpt-4").call_llm(
75-
"surf is 2ft", "recommend a board"
76-
)
77-
78-
assert result == "Unable to generate GPT response."
70+
llm = gpt.OpenAILlm("sk-key", "gpt-4")
71+
with pytest.raises(Exception, match="quota exceeded"):
72+
llm.call_llm("surf is 2ft")

tests/test_helper.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ def test_print_outputs_no_ocean_data(mocker, capsys):
347347
"color": "blue",
348348
}
349349
mocker.patch("src.api.forecast", return_value={})
350-
helper.print_outputs(ocean_data, arguments, "", (None, ""))
350+
helper.print_outputs(ocean_data, arguments)
351351
assert "No ocean data at this location." in capsys.readouterr().out
352352

353353

@@ -371,14 +371,17 @@ def test_print_outputs_valid_data(mocker, capsys):
371371
"color": "blue",
372372
}
373373
mocker.patch("src.api.forecast", return_value={})
374-
helper.print_outputs(ocean_data, arguments, "", (None, ""))
374+
helper.print_outputs(ocean_data, arguments)
375375
out = capsys.readouterr().out
376376
assert "Santa Cruz" in out
377377
assert "Wave Height: 3.0" in out
378378

379379

380380
def test_print_outputs_with_gpt(mocker, capsys):
381-
"""print_outputs calls print_gpt and prints its response when gpt=True."""
381+
"""
382+
print_outputs calls get_gpt_response
383+
and prints its response when gpt=True.
384+
"""
382385
ocean_data = {
383386
"Height": 3.0,
384387
"Lat": 36.97,
@@ -398,9 +401,11 @@ def test_print_outputs_with_gpt(mocker, capsys):
398401
"gpt": True,
399402
}
400403
mocker.patch("src.api.forecast", return_value={})
401-
mocker.patch("src.helper.print_gpt", return_value="GPT says: go surf!")
404+
mocker.patch(
405+
"src.helper.get_gpt_response", return_value="GPT says: go surf!"
406+
)
402407

403-
result = helper.print_outputs(ocean_data, arguments, "prompt", (None, ""))
408+
result = helper.print_outputs(ocean_data, arguments)
404409

405410
assert result == "GPT says: go surf!"
406411
assert "GPT says: go surf!" in capsys.readouterr().out
@@ -421,12 +426,12 @@ def test_set_location_unpacks_dict():
421426

422427

423428
# ---------------------------------------------------------------------------
424-
# print_gpt
429+
# get_gpt_response
425430
# ---------------------------------------------------------------------------
426431

427432

428-
def test_print_gpt_uses_openai_when_key_is_long_enough(mocker):
429-
"""print_gpt uses OpenAILlm when the API key is at least 5 chars."""
433+
def test_get_gpt_response_uses_openai_when_key_is_long_enough(mocker):
434+
"""get_gpt_response uses OpenAILlm when the API key is at least 5 chars."""
430435
surf_data = {
431436
"Location": "Santa Cruz",
432437
"Height": "3",
@@ -436,9 +441,7 @@ def test_print_gpt_uses_openai_when_key_is_long_enough(mocker):
436441
}
437442
mock_llm = mocker.MagicMock()
438443
mock_llm.call_llm.return_value = "openai response"
439-
mocker.patch("src.helper.gpt.OpenAILlm", return_value=mock_llm)
440-
result = helper.print_gpt(
441-
surf_data, "any prompt", ("sk-validkey", "gpt-4")
442-
)
444+
mocker.patch("src.helper.get_llm_client", return_value=mock_llm)
445+
result = helper.get_gpt_response(surf_data)
443446
assert result == "openai response"
444447
mock_llm.call_llm.assert_called_once()

tests/test_root.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)