Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ OPENAI_API_KEY=ADD-YOUR-OPENAI_API_KEY-HERE
ENVIRONMENT=dev
DOCKERHUB_USERNAME=ADD-YOUR-DOCKERHUB_USERNAME-HERE
DOCKERHUB_ACCESS_TOKEN=ADD-YOUR-DOCKERHUB_ACCESS_TOKEN-HERE
LLM_TOOL_CHOICE=required
LOGGING_LEVEL=20
MYSQL_HOST=your-mysql-host.com
MYSQL_PORT=3306
MYSQL_USER=your-username
MYSQL_PASSWORD=your-password
MYSQL_DATABASE=your-database-name
MYSQL_CHARSET=utf8mb4
LLM_TOOL_CHOICE=required
PYTHONPATH=./venv:./
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
# This runs on Debian Linux.
FROM python:3.13-slim-trixie AS base

LABEL maintainer="Lawrence McDaniel <lpm0073@gmail.com>" \
description="Docker image for the StackademyAssistent" \
license="GNU AGPL v3" \
vcs-url="https://github.com/FullStackWithLawrence/agentic-ai-workflow" \
org.opencontainers.image.title="StackademyAssistent" \
org.opencontainers.image.version="0.1.0" \
org.opencontainers.image.authors="Lawrence McDaniel <lpm0073@gmail.com>" \
org.opencontainers.image.url="https://FullStackWithLawrence.github.io/agentic-ai-workflow/" \
org.opencontainers.image.source="https://github.com/FullStackWithLawrence/agentic-ai-workflow" \
org.opencontainers.image.documentation="https://FullStackWithLawrence.github.io/agentic-ai-workflow/"


FROM base AS requirements

Expand Down
2 changes: 1 addition & 1 deletion app/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# DO NOT EDIT.
# Managed via automated CI/CD in .github/workflows/semanticVersionBump.yml.
__version__ = "0.1.4"
__version__ = "0.1.0"
5 changes: 5 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def __init__(self):
"MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE"
)

@property
def connection_string(self) -> str:
"""Return the database connection string."""
return f"{self.user}@{self.host}:{self.port}/{self.database}"

def get_connection(self) -> pymysql.Connection:
"""
Create and return a new MySQL connection.
Expand Down
2 changes: 2 additions & 0 deletions app/logging_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def setup_logging(level: int = LOGGING_LEVEL) -> logging.Logger:
handlers=[logging.StreamHandler(sys.stdout)], # This logs to console
)

logging.getLogger("httpx").setLevel(logging.WARNING)

return logging.getLogger(__name__)


Expand Down
5 changes: 3 additions & 2 deletions app/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from app.logging_config import get_logger, setup_logging
from app.settings import LLM_ASSISTANT_NAME, LLM_TOOL_CHOICE
from app.stackademy import stackademy_app
from app.utils import dump_json_colored
from app.utils import color_text, dump_json_colored


setup_logging()
Expand Down Expand Up @@ -114,7 +114,8 @@ def process_tool_calls(message: ChatCompletionMessage) -> list[str]:
role="assistant", content=assistant_content, tool_calls=tool_calls_param, name=LLM_ASSISTANT_NAME
)
)
logger.info("Function call detected: %s with args %s", function_name, function_args)
msg = f"Calling function: {function_name} with args {json.dumps(function_args)}"
logger.info(color_text(msg, "green"))

function_result = handle_function_call(function_name, function_args)

Expand Down
20 changes: 10 additions & 10 deletions app/stackademy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.database import db
from app.exceptions import ConfigurationException
from app.logging_config import get_logger, setup_logging
from app.utils import color_text


setup_logging()
Expand Down Expand Up @@ -67,7 +68,6 @@ def _log_success(self, message: str) -> None:
def tool_factory_get_courses(self) -> ChatCompletionFunctionToolParam:
"""LLM Factory function to create a tool for getting courses"""
schema = StackademyGetCoursesParams.model_json_schema()
schema["required"] = [] # Both parameters are optional
return ChatCompletionFunctionToolParam(
type="function",
function={
Expand All @@ -80,7 +80,6 @@ def tool_factory_get_courses(self) -> ChatCompletionFunctionToolParam:
def tool_factory_register(self) -> ChatCompletionFunctionToolParam:
"""LLMFactory function to create a tool for registering a user"""
schema = StackademyRegisterCourseParams.model_json_schema()
schema["required"] = ["course_code", "email", "full_name"] # All parameters are required
return ChatCompletionFunctionToolParam(
type="function",
function={
Expand Down Expand Up @@ -115,7 +114,7 @@ def get_courses(self, description: Optional[str] = None, max_cost: Optional[floa
Returns:
List[Dict[str, Any]]: List of courses matching the criteria
"""
# Base query

query = """
SELECT
c.course_code,
Expand All @@ -128,7 +127,6 @@ def get_courses(self, description: Optional[str] = None, max_cost: Optional[floa
LEFT JOIN courses prerequisite ON c.prerequisite_id = prerequisite.course_id
"""

# Build WHERE clause dynamically
where_conditions = []
params = []

Expand All @@ -140,14 +138,16 @@ def get_courses(self, description: Optional[str] = None, max_cost: Optional[floa
where_conditions.append("c.cost <= %s")
params.append(max_cost)

# Add WHERE clause if we have conditions
if where_conditions:
query += " WHERE " + " AND ".join(where_conditions)

query += " ORDER BY c.prerequisite_id"
logger.info("get_courses() executing db query with params: %s", params)

try:
return self.db.execute_query(query, tuple(params))
retval = self.db.execute_query(query, tuple(params))
msg = f"get_courses() retrieved {len(retval)} rows from {self.db.connection_string}"
logger.info(color_text(msg, "green"))
return retval
# pylint: disable=broad-except
except Exception as e:
logger.error("Failed to retrieve courses: %s", e)
Expand Down Expand Up @@ -190,9 +190,9 @@ def register_course(self, course_code: str, email: str, full_name: str) -> bool:
if MISSING in (course_code, email, full_name):
raise ConfigurationException("Missing required registration parameters.")

full_name = full_name.title().strip()
email = email.lower().strip()
course_code = course_code.upper().strip()
full_name = full_name.title().strip() if isinstance(full_name, str) else full_name
email = email.lower().strip() if isinstance(email, str) else email
course_code = course_code.upper().strip() if isinstance(course_code, str) else course_code

logger.info("Registering %s (%s) for course %s...", full_name, email, course_code)
if not self.verify_course(course_code):
Expand Down
35 changes: 29 additions & 6 deletions app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@
import json


# ANSI color codes
colors = {
"blue": "\033[94m", # Bright blue
"green": "\033[92m", # Bright green
"reset": "\033[0m", # Reset to default color
}


def color_text(text, color="blue"):
"""
Colors a string as blue or green.

Args:
text (str): The string to color
color (str): Color to apply - either "blue" or "green" (default: "blue")

Returns:
str: The colored string with ANSI escape codes

Raises:
ValueError: If color is not "blue" or "green"
"""

if color not in ["blue", "green"]:
raise ValueError("Color must be either 'blue' or 'green'")

return f"{colors[color]}{text}{colors['reset']}"


def dump_json_colored(data, color="reset", indent=2, sort_keys=False):
"""
Dumps a JSON dictionary with colored text output.
Expand All @@ -23,12 +52,6 @@ def dump_json_colored(data, color="reset", indent=2, sort_keys=False):
ValueError: If color is not "blue" or "green"
TypeError: If data is not JSON serializable
"""
# ANSI color codes
colors = {
"blue": "\033[94m", # Bright blue
"green": "\033[92m", # Bright green
"reset": "\033[0m", # Reset to default color
}

if color not in ["blue", "green"]:
raise ValueError("Color must be either 'blue' or 'green'")
Expand Down
24 changes: 24 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
[project]
name = "StackademyAI"
version = "0.1.0"
requires-python = ">=3.13"
description = "StackademyAI: an AI-powered marketing agent"
authors = [{ name = "Lawrence McDaniel", email = "lpm0073@gmail.com" }]
license = { file = "LICENSE" }
keywords = ["AI", "API", "Python"]
readme = "README.md"
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: GNU AGPL v3 or later (AGPLv3+)",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Operating System :: POSIX :: Linux",
"Intended Audience :: Developers",
"Framework :: Django",
"Framework :: Django REST framework",
"Topic :: Software Development :: Libraries :: Application Interfaces",
"Topic :: Software Development :: Libraries :: API",
"Natural Language :: English",
"Development Status :: 4 - Beta"
]

[tool.isort]
profile = "black"
lines_after_imports = 2
Expand Down
13 changes: 11 additions & 2 deletions release.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,23 @@ module.exports = {
},
],
"@semantic-release/github",
[
"@semantic-release/exec",
{
prepareCmd: "python scripts/bump_version.py ${nextRelease.version}",
},
],
[
"@semantic-release/git",
{
assets: [
"CHANGELOG.md",
"client/package.json",
"client/package-lock.json",
"requirements/prod.txt",
"app/__version__.py",
"pyproject.toml",
"Dockerfile",
"package.json",
"package-lock.json",
],
message:
"chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
Expand Down
1 change: 0 additions & 1 deletion run.sh

This file was deleted.

60 changes: 60 additions & 0 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""
Script to update the semantic version in pyproject.toml and Dockerfile.
Called automatically from semantic-release hooks.
see: release.config.js in the root directory.

Usage:
python scripts/bump_version.py <new_version>

Updates:
- app/__version__.py
- pyproject.toml
- Dockerfile
"""

import re
import sys
from pathlib import Path


def update_version_in_file(filepath, pattern, replacement):
"""Update the version in the specified file."""
path = Path(filepath)
text = path.read_text(encoding="utf-8")
new_text = re.sub(pattern, replacement, text)
path.write_text(new_text, encoding="utf-8")


def main():
"""Main function to update version in multiple files."""
if len(sys.argv) != 2:
print("Usage: python bump_version.py <new_version>")
sys.exit(1)
new_version = sys.argv[1]

# Validate semantic version: ##.##.##
if not re.match(r"^\d+\.\d+\.\d+$", new_version):
print("Error: Version must be in format ##.##.## (e.g., 0.1.20)")
sys.exit(1)

# Update __version__.py
update_version_in_file("app/__version__.py", r'__version__\s*=\s*["\'].*?["\']', f'__version__ = "{new_version}"')

# Update pyproject.toml
update_version_in_file("pyproject.toml", r'version\s*=\s*["\'].*?["\']', f'version = "{new_version}"')

# Update Dockerfile (example: ARG VERSION=...)
update_version_in_file(
"Dockerfile",
r'org\.opencontainers\.image\.version="[^"]+"',
f'org.opencontainers.image.version="{new_version}"',
)

print(
f"Version updated to {new_version} in __version__.py, pyproject.toml, Dockerfile and helm/charts/smarter/Chart.yaml"
)


if __name__ == "__main__":
main()
Loading