Skip to content

Commit 6704f6e

Browse files
Merge pull request #8 from renansantosmendes/feat/add-chat-model
add chatpgl class and files
2 parents b47ca4e + 94c23ef commit 6704f6e

7 files changed

Lines changed: 556 additions & 5 deletions

File tree

.github/workflows/tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ jobs:
4747
continue-on-error: true
4848

4949
- name: Run tests
50+
env:
51+
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
5052
run: uv run pytest tests/ -v --cov=pgl_utils --cov-report=xml --cov-report=term
5153

5254
- name: Upload coverage reports

pgl_utils/genai/llm.py

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,195 @@
33
"""
44

55

6-
def placeholder():
6+
# Standard library
7+
import os
8+
import time
9+
from typing import Any, Iterator, List, Optional
10+
11+
# Third-party
12+
from groq import Groq
13+
from langchain_community.chat_message_histories import ChatMessageHistory
14+
from langchain_core.callbacks.manager import CallbackManagerForLLMRun
15+
from langchain_core.chat_history import BaseChatMessageHistory
16+
from langchain_core.language_models.chat_models import BaseChatModel
17+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
18+
from langchain_core.outputs import ChatResult
19+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
20+
from langchain_core.runnables.history import RunnableWithMessageHistory
21+
from langchain_groq import ChatGroq
22+
from pydantic import ConfigDict
23+
24+
25+
_PGL_API_KEY = os.environ.get("GROQ_API_KEY", "")
26+
_FALLBACK_MODEL = "llama-3.3-70b-versatile"
27+
28+
29+
def _fetch_available_models() -> List:
30+
client = Groq(api_key=_PGL_API_KEY)
31+
models = client.models.list()
32+
return sorted(models.data, key=lambda m: m.created, reverse=True)
33+
34+
35+
def _resolve_default_model() -> str:
36+
try:
37+
models = _fetch_available_models()
38+
llama = next((m.id for m in models if "llama-3.3" in m.id), None)
39+
return llama if llama else _FALLBACK_MODEL
40+
except Exception:
41+
return _FALLBACK_MODEL
42+
43+
44+
_DEFAULT_MODEL = _resolve_default_model()
45+
46+
47+
class ChatPGL(BaseChatModel):
748
"""
8-
Placeholder function
49+
ChatGroq wrapper pre-configured for PGL classes.
50+
Students do not need to provide an API key.
51+
Uses LangChain's ChatMessageHistory for conversation memory.
952
"""
10-
return "GenAI LLM utilities"
53+
54+
model_config = ConfigDict(arbitrary_types_allowed=True)
55+
56+
model: str = _DEFAULT_MODEL
57+
temperature: float = 0.7
58+
max_tokens: int = 1024
59+
stream_delay: float = 0.02
60+
system_prompt: Optional[str] = None
61+
session_id: str = "default"
62+
_client: Any = None
63+
_store: dict = {}
64+
_chain: Any = None
65+
66+
def __init__(self, **kwargs):
67+
if "model" in kwargs:
68+
available = [m.id for m in _fetch_available_models()]
69+
if kwargs["model"] not in available:
70+
raise ValueError(
71+
f"Model '{kwargs['model']}' is not available.\n"
72+
f"Available models: {available}"
73+
)
74+
super().__init__(**kwargs)
75+
76+
self._store = {}
77+
self._client = ChatGroq(
78+
model=self.model,
79+
temperature=self.temperature,
80+
max_tokens=self.max_tokens,
81+
api_key=_PGL_API_KEY,
82+
)
83+
self._chain = self._build_chain()
84+
85+
def _build_chain(self) -> RunnableWithMessageHistory:
86+
system = self.system_prompt or "You are a helpful assistant."
87+
88+
prompt = ChatPromptTemplate.from_messages([
89+
("system", system),
90+
MessagesPlaceholder(variable_name="history"),
91+
("human", "{input}"),
92+
])
93+
94+
return RunnableWithMessageHistory(
95+
prompt | self._client,
96+
self._get_session_history,
97+
input_messages_key="input",
98+
history_messages_key="history",
99+
)
100+
101+
def _get_session_history(self, session_id: str) -> BaseChatMessageHistory:
102+
if session_id not in self._store:
103+
self._store[session_id] = ChatMessageHistory()
104+
return self._store[session_id]
105+
106+
def invoke(self, input: Any, session_id: Optional[str] = None, **kwargs) -> Any:
107+
"""
108+
Runs inference using LangChain memory. Conversation history is
109+
automatically managed per session_id.
110+
111+
Args:
112+
input: A string message.
113+
session_id: Conversation session identifier. Defaults to self.session_id.
114+
"""
115+
sid = session_id or self.session_id
116+
return self._chain.invoke(
117+
{"input": input},
118+
config={"configurable": {"session_id": sid}},
119+
**kwargs,
120+
)
121+
122+
def clear_memory(self, session_id: Optional[str] = None):
123+
"""Clears the conversation memory for the given session."""
124+
sid = session_id or self.session_id
125+
if sid in self._store:
126+
self._store[sid].clear()
127+
128+
def get_memory(self, session_id: Optional[str] = None) -> List[BaseMessage]:
129+
"""Returns the conversation history for the given session."""
130+
sid = session_id or self.session_id
131+
return self._get_session_history(sid).messages
132+
133+
@staticmethod
134+
def list_models() -> List[str]:
135+
"""Fetches and returns the list of available models from the Groq API, sorted by most recent."""
136+
return [m.id for m in _fetch_available_models()]
137+
138+
@property
139+
def _llm_type(self) -> str:
140+
return "chat-pgl"
141+
142+
@property
143+
def _identifying_params(self) -> dict:
144+
return {
145+
"model": self.model,
146+
"temperature": self.temperature,
147+
"max_tokens": self.max_tokens,
148+
"stream_delay": self.stream_delay,
149+
}
150+
151+
def _generate(
152+
self,
153+
messages: List[BaseMessage],
154+
stop: Optional[List[str]] = None,
155+
run_manager: Optional[CallbackManagerForLLMRun] = None,
156+
**kwargs: Any,
157+
) -> ChatResult:
158+
return self._client._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
159+
160+
def _stream(
161+
self,
162+
messages: List[BaseMessage],
163+
stop: Optional[List[str]] = None,
164+
run_manager: Optional[CallbackManagerForLLMRun] = None,
165+
**kwargs: Any,
166+
) -> Iterator[Any]:
167+
yield from self._client._stream(messages, stop=stop, run_manager=run_manager, **kwargs)
168+
169+
def bind_tools(self, tools, **kwargs):
170+
return self._client.bind_tools(tools, **kwargs)
171+
172+
def streamed_invoke(self, input: Any, session_id: Optional[str] = None, stream_delay: Optional[float] = None) -> str:
173+
"""
174+
Runs inference with a typing effect, printing the response token by token.
175+
Conversation history is automatically managed per session_id.
176+
Returns the full response as a string when complete.
177+
178+
Args:
179+
input: A string message.
180+
session_id: Conversation session identifier. Defaults to self.session_id.
181+
stream_delay: Seconds to wait between each chunk. Defaults to self.stream_delay.
182+
"""
183+
sid = session_id or self.session_id
184+
delay = stream_delay if stream_delay is not None else self.stream_delay
185+
full_response = ""
186+
187+
for chunk in self._chain.stream(
188+
{"input": input},
189+
config={"configurable": {"session_id": sid}},
190+
):
191+
token = chunk.content
192+
print(token, end="", flush=True)
193+
full_response += token
194+
time.sleep(delay)
195+
196+
print()
197+
return full_response

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pgl-utils"
7-
version = "0.1.8"
7+
version = "0.1.9"
88
description = "Machine Learning, Deep Learning, and GenAI utilities for PUC and IBMEC post-graduation students"
99
readme = "README.md"
1010
requires-python = ">=3.10"

requirements.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ python-dotenv
1616
networkx>=2.8.0
1717
matplotlib>=3.5.0
1818
seaborn>=0.12.0
19+
20+
# Generative AI (added based on llm.py imports)
21+
langchain-groq
22+
langchain-core
23+
langchain-community
24+
pydantic
1925

2026
# Optional: Generative AI
2127
# openai>=0.27.0

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
setup(
1111
name="pgl-utils",
12-
version="0.1.8",
12+
version="0.1.9",
1313
author="Your Name",
1414
author_email="your.email@example.com",
1515
description="Machine Learning, Deep Learning, and GenAI utilities for Post-Graduation Lectures",

tests/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,17 @@
11
import matplotlib
22
matplotlib.use('Agg')
3+
4+
# groq.Groq must be mocked before pgl_utils.genai.llm is first imported,
5+
# because llm.py calls _resolve_default_model() at module level which hits the API.
6+
# conftest.py is loaded before any test file, so this pre-load is safe.
7+
from unittest.mock import MagicMock, patch as _patch
8+
9+
_mock_model = MagicMock()
10+
_mock_model.id = "llama-3.3-70b-versatile"
11+
_mock_model.created = 1_000_000
12+
13+
with _patch("groq.Groq") as _groq_mock:
14+
_groq_mock.return_value.models.list.return_value.data = [_mock_model]
15+
import pgl_utils.genai.llm # noqa: E402
16+
17+
del _mock_model, _groq_mock, _patch, MagicMock

0 commit comments

Comments
 (0)