Skip to content

Commit 463693e

Browse files
authored
Merge pull request #198 from ryansurf/feat-art
Feat art
2 parents 55a7a12 + 428bf18 commit 463693e

8 files changed

Lines changed: 121 additions & 69 deletions

File tree

poetry.lock

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "cli-surf"
3-
version = "2.3.1"
3+
version = "2.4.0"
44
description = "Command-line surf report tool"
55
license = "MIT"
66
authors = ["ryansurf <your@email.com>"] # TODO: email
@@ -50,6 +50,7 @@ httpx = "^0.28.1"
5050
debugpy = "^1.8.20"
5151
cachetools = "^7.0.5"
5252
mangum = "^0.21.0"
53+
nest-asyncio = "^1.6.0"
5354

5455
[tool.poetry.group.dev.dependencies]
5556
# command: `poetry add --group dev <package-name>`

src/art.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,40 @@
2727
"bold_white": "\033[1;37m",
2828
}
2929

30+
ANSI_TO_CSS = {
31+
"\033[0;31m": "color:#e06c75", # red
32+
"\033[0;32m": "color:#98c379", # green
33+
"\033[0;33m": "color:#e5c07b", # yellow
34+
"\033[0;34m": "color:#61afef", # blue
35+
"\033[0;35m": "color:#c678dd", # purple
36+
"\033[0;36m": "color:#56b6c2", # teal
37+
"\033[0;94m": "color:#82aaff", # light_blue
38+
"\033[0;37m": "color:#abb2bf", # white
39+
"\033[1;31m": "color:#e06c75;font-weight:bold",
40+
"\033[1;32m": "color:#98c379;font-weight:bold",
41+
"\033[1;33m": "color:#e5c07b;font-weight:bold",
42+
"\033[1;34m": "color:#61afef;font-weight:bold",
43+
"\033[1;35m": "color:#c678dd;font-weight:bold",
44+
"\033[1;36m": "color:#56b6c2;font-weight:bold",
45+
"\033[1;37m": "color:#abb2bf;font-weight:bold",
46+
"\033[39m": "color:inherit", # default
47+
"\033[0m": None, # reset → close span
48+
}
49+
50+
51+
def ansi_to_html(text: str) -> str:
52+
html = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
53+
for code, css in ANSI_TO_CSS.items():
54+
if css is not None:
55+
html = html.replace(code, f'<span style="{css}">')
56+
else:
57+
html = html.replace(code, "</span>")
58+
return f"""
59+
<pre style="padding:1em;font-family:monospace;line-height:1.4">
60+
{html}
61+
</pre>
62+
"""
63+
3064

3165
def print_wave(show_wave, show_large_wave, color):
3266
"""

src/gpt.py

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,41 @@
33
"""
44

55
import logging
6+
from abc import ABC
7+
from typing import Any
68

79
from g4f.client import Client
810
from openai import OpenAI
911

1012
logger = logging.getLogger(__name__)
1113

1214

13-
def simple_gpt(surf_summary, gpt_prompt):
14-
"""
15-
Surf summary is a report of todays data, ex: The surf is 4
16-
feet with a 10 second period... GPT Prompt is what kind of
17-
report the user wants, loaded in from the environment vars
18-
Using: https://github.com/xtekky/gpt4free
19-
"""
20-
try:
21-
client = Client()
22-
response = client.chat.completions.create(
23-
model="gpt-3.5-turbo",
24-
messages=[{"role": "user", "content": surf_summary + gpt_prompt}],
25-
)
26-
return response.choices[0].message.content
27-
except Exception as e:
28-
logger.error("GPT (free) request failed: %s", e)
29-
return "Unable to generate GPT response."
30-
31-
32-
def openai_gpt(surf_summary, gpt_prompt, api_key, model):
33-
"""
34-
Surf Summary is a brief summary of the surf data(height, period)
35-
and gpt_prompt is the personal report the user wants(reccomend a
36-
board, etc). gpt_prompt in .env
37-
Uses openai's GPT, needs an API key
38-
https://platform.openai.com/docs/api-reference/introduction
39-
"""
40-
try:
41-
client = OpenAI(api_key=api_key)
42-
chat_completion = client.chat.completions.create(
43-
messages=[
44-
{
45-
"role": "user",
46-
"content": surf_summary + gpt_prompt,
47-
}
48-
],
49-
model=model,
50-
)
51-
return chat_completion.choices[0].message.content
52-
except Exception as e:
53-
logger.error("OpenAI request failed: %s", e)
54-
return "Unable to generate GPT response."
15+
class Llm(ABC):
16+
def __init__(self, model):
17+
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."
32+
33+
34+
class FreeGpt(Llm):
35+
def __init__(self, model):
36+
super().__init__(model)
37+
self.client = Client()
38+
39+
40+
class OpenAILlm(Llm):
41+
def __init__(self, api_key, model):
42+
super().__init__(model)
43+
self.client = OpenAI(api_key=api_key)

src/helper.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,5 +390,5 @@ def print_gpt(surf_data, gpt_prompt, gpt_info):
390390
api_key, gpt_model = gpt_info
391391
MIN_KEY_LEN = 5
392392
if not api_key or len(api_key) < MIN_KEY_LEN:
393-
return gpt.simple_gpt(summary, gpt_prompt)
394-
return gpt.openai_gpt(summary, gpt_prompt, api_key, gpt_model)
393+
return gpt.FreeGpt(gpt_model).call_llm(summary, gpt_prompt)
394+
return gpt.OpenAILlm(api_key, gpt_model).call_llm(summary, gpt_prompt)

src/server.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
import uvicorn
1111
from fastapi import FastAPI, Request
1212
from fastapi.middleware.cors import CORSMiddleware
13-
from fastapi.responses import FileResponse, PlainTextResponse
13+
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
1414
from fastapi.templating import Jinja2Templates
1515

1616
from src import cli
17+
from src.art import ansi_to_html
1718
from src.settings import ServerSettings
1819

1920
logger = logging.getLogger(__name__)
@@ -63,7 +64,12 @@ async def default_route(request: Request):
6364
f = io.StringIO()
6465
with redirect_stdout(f):
6566
surf.run(args=passed_args)
66-
return f.getvalue()
67+
output = f.getvalue()
68+
69+
accept = request.headers.get("accept", "")
70+
if "text/html" in accept:
71+
return HTMLResponse(content=ansi_to_html(output))
72+
return PlainTextResponse(output)
6773

6874
return app
6975

tests/test_gpt.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,51 +18,61 @@ def _make_chat_response(content):
1818
return response
1919

2020

21-
def test_simple_gpt_returns_model_content(mocker):
22-
"""simple_gpt returns the text content from the g4f response."""
21+
def test_free_gpt_returns_model_content(mocker):
22+
"""FreeGpt.call_llm returns the text content from the g4f response."""
2323
mock_client = Mock()
2424
mock_client.chat.completions.create.return_value = _make_chat_response(
2525
"Great surf day!"
2626
)
2727
mocker.patch("src.gpt.Client", return_value=mock_client)
2828

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

3133
assert result == "Great surf day!"
3234
mock_client.chat.completions.create.assert_called_once()
3335

3436

35-
def test_simple_gpt_returns_fallback_on_exception(mocker):
36-
"""simple_gpt returns the error string when the g4f client raises."""
37-
mocker.patch("src.gpt.Client", side_effect=Exception("API down"))
37+
def test_free_gpt_returns_fallback_on_exception(mocker):
38+
"""FreeGpt.call_llm returns the error string when the client raises."""
39+
mock_client = Mock()
40+
mock_client.chat.completions.create.side_effect = Exception("API down")
41+
mocker.patch("src.gpt.Client", return_value=mock_client)
3842

39-
result = gpt.simple_gpt("surf is 4ft", "what board?")
43+
result = gpt.FreeGpt("gpt-3.5-turbo").call_llm(
44+
"surf is 4ft", "what board?"
45+
)
4046

4147
assert result == "Unable to generate GPT response."
4248

4349

44-
def test_openai_gpt_returns_model_content(mocker):
45-
"""openai_gpt returns the text content from the OpenAI response."""
50+
def test_openai_llm_returns_model_content(mocker):
51+
"""OpenAILlm.call_llm returns the text content from the OpenAI response."""
4652
mock_client = Mock()
4753
mock_client.chat.completions.create.return_value = _make_chat_response(
4854
"Bring your longboard."
4955
)
5056
mocker.patch("src.gpt.OpenAI", return_value=mock_client)
5157

52-
result = gpt.openai_gpt(
53-
"surf is 2ft", "recommend a board", "sk-testkey", "gpt-4"
58+
result = gpt.OpenAILlm("sk-testkey", "gpt-4").call_llm(
59+
"surf is 2ft", "recommend a board"
5460
)
5561

5662
assert result == "Bring your longboard."
5763
mock_client.chat.completions.create.assert_called_once()
5864

5965

60-
def test_openai_gpt_returns_fallback_on_exception(mocker):
61-
"""openai_gpt returns the error string when the OpenAI client raises."""
62-
mocker.patch("src.gpt.OpenAI", side_effect=Exception("quota exceeded"))
66+
def test_openai_llm_returns_fallback_on_exception(mocker):
67+
"""OpenAILlm.call_llm returns the error string when the client raises."""
68+
mock_client = Mock()
69+
mock_client.chat.completions.create.side_effect = Exception(
70+
"quota exceeded"
71+
)
72+
mocker.patch("src.gpt.OpenAI", return_value=mock_client)
6373

64-
result = gpt.openai_gpt(
65-
"surf is 2ft", "recommend a board", "sk-key", "gpt-4"
74+
result = gpt.OpenAILlm("sk-key", "gpt-4").call_llm(
75+
"surf is 2ft", "recommend a board"
6676
)
6777

6878
assert result == "Unable to generate GPT response."

tests/test_helper.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,19 +426,19 @@ def test_set_location_unpacks_dict():
426426

427427

428428
def test_print_gpt_uses_openai_when_key_is_long_enough(mocker):
429-
"""print_gpt calls openai_gpt when the API key is at least 5 chars."""
429+
"""print_gpt uses OpenAILlm when the API key is at least 5 chars."""
430430
surf_data = {
431431
"Location": "Santa Cruz",
432432
"Height": "3",
433433
"Swell Direction": "270",
434434
"Period": "12",
435435
"Unit": "ft",
436436
}
437-
mock_openai = mocker.patch(
438-
"src.helper.gpt.openai_gpt", return_value="openai response"
439-
)
437+
mock_llm = mocker.MagicMock()
438+
mock_llm.call_llm.return_value = "openai response"
439+
mocker.patch("src.helper.gpt.OpenAILlm", return_value=mock_llm)
440440
result = helper.print_gpt(
441441
surf_data, "any prompt", ("sk-validkey", "gpt-4")
442442
)
443443
assert result == "openai response"
444-
mock_openai.assert_called_once()
444+
mock_llm.call_llm.assert_called_once()

0 commit comments

Comments
 (0)