diff --git a/.env-example b/.env-example index 3980adc..c6696f7 100644 --- a/.env-example +++ b/.env-example @@ -2,6 +2,7 @@ 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 @@ -9,4 +10,4 @@ MYSQL_USER=your-username MYSQL_PASSWORD=your-password MYSQL_DATABASE=your-database-name MYSQL_CHARSET=utf8mb4 -LLM_TOOL_CHOICE=required +PYTHONPATH=./venv:./ diff --git a/Dockerfile b/Dockerfile index cc1d8ee..7b18ada 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,17 @@ # This runs on Debian Linux. FROM python:3.13-slim-trixie AS base +LABEL maintainer="Lawrence McDaniel " \ + 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 " \ + 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 diff --git a/app/__version__.py b/app/__version__.py index 9cb6745..3637529 100644 --- a/app/__version__.py +++ b/app/__version__.py @@ -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" diff --git a/app/database.py b/app/database.py index da6948a..c373ce7 100644 --- a/app/database.py +++ b/app/database.py @@ -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. diff --git a/app/logging_config.py b/app/logging_config.py index ebfb46b..0697545 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -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__) diff --git a/app/prompt.py b/app/prompt.py index d324f26..3eab87d 100644 --- a/app/prompt.py +++ b/app/prompt.py @@ -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() @@ -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) diff --git a/app/stackademy.py b/app/stackademy.py index b1f7f16..4d39b13 100644 --- a/app/stackademy.py +++ b/app/stackademy.py @@ -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() @@ -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={ @@ -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={ @@ -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, @@ -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 = [] @@ -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) @@ -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): diff --git a/app/utils.py b/app/utils.py index bfa0015..7c3d3f9 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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. @@ -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'") diff --git a/pyproject.toml b/pyproject.toml index 52a91b5..7f45b4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/release.config.js b/release.config.js index e52391c..16b9d9b 100644 --- a/release.config.js +++ b/release.config.js @@ -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}", diff --git a/run.sh b/run.sh deleted file mode 100644 index 6755396..0000000 --- a/run.sh +++ /dev/null @@ -1 +0,0 @@ -docker run -e ENVIRONMENT=dev -p 80:80 -d openai-hello-world diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..b6025a6 --- /dev/null +++ b/scripts/bump_version.py @@ -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 + +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 ") + 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()