diff --git a/.gitignore b/.gitignore index e6fbdb6..2c9016b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ docs/recordings/* etc/ example_projects/ lib/ +logs/ share/ src/ansari_backend.egg-info/* tmp/ diff --git a/docs/code_outputs/sample_logging_exception_output.png b/docs/code_outputs/sample_logging_exception_output.png new file mode 100644 index 0000000..f5babb0 Binary files /dev/null and b/docs/code_outputs/sample_logging_exception_output.png differ diff --git a/src/ansari/ansari_logger.py b/src/ansari/ansari_logger.py index 05d34a4..4c3d1e2 100644 --- a/src/ansari/ansari_logger.py +++ b/src/ansari/ansari_logger.py @@ -1,11 +1,76 @@ # This file provides a standard Python logging instance for the caller file (e.g., main_api.py, etc.). import logging -import sys +import os +import re +import time + +from rich.console import Console +from rich.logging import RichHandler +from rich.traceback import install as install_rich_traceback from ansari.config import get_settings +# Install rich traceback handler globally +install_rich_traceback( + show_locals=True, + max_frames=10, + suppress=[], + width=None, + word_wrap=True, +) + + +def create_file_handler(name: str, logging_level: str) -> logging.FileHandler: + """Creates and configures a file handler for logging. + + Args: + name (str): The name of the module for the log file. + logging_level (str): The logging level to set for the handler. + + Returns: + logging.FileHandler: Configured file handler instance. + """ + # Ensure logs directory exists + log_dir = os.path.join(os.getcwd(), "logs") + os.makedirs(log_dir, exist_ok=True) + + log_file = os.path.join(log_dir, f"{name}.log") + file_handler = logging.FileHandler( + filename=log_file, + mode="a", # Append mode + encoding="utf-8", # Use UTF-8 encoding to support Unicode characters + ) + file_handler.setLevel(logging_level) + + # Custom formatter for files with forward slashes and function name in square brackets + class VSCodePathFormatter(logging.Formatter): + # ANSI color code regex pattern + ANSI_ESCAPE_PATTERN = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + def format(self, record): + # Format path with forward slashes and function name in square brackets + path_format = f"{record.name.replace('.','/')}:{record.lineno} [{record.funcName}()]" + + # Format time without milliseconds + # Override the default formatTime to remove milliseconds + created = self.converter(record.created) + time_format = time.strftime("%Y-%m-%d %H:%M:%S", created) + + # Get the message and strip any ANSI color codes + message = record.getMessage() + clean_message = self.ANSI_ESCAPE_PATTERN.sub("", message) + + # Combine everything + return f"{time_format} | {record.levelname} | {path_format} | {clean_message}" + + # Use the custom formatter for files + file_formatter = VSCodePathFormatter() # No datefmt needed as we're formatting time manually + file_handler.setFormatter(file_formatter) + return file_handler + + def get_logger(name: str) -> logging.Logger: """Creates and returns a logger instance for the specified module. @@ -17,6 +82,14 @@ def get_logger(name: str) -> logging.Logger: """ logging_level = get_settings().LOGGING_LEVEL.upper() + # Create a Rich console for logging + console = Console( + highlight=True, # Syntax highlighting + markup=True, # Enable Rich markup + log_path=False, # Don't write to a log file directly - we'll handle that separately + log_time_format="%Y-%m-%d %H:%M:%S", + ) + # Create a logger logger = logging.getLogger(name) @@ -27,20 +100,25 @@ def get_logger(name: str) -> logging.Logger: # Set the logging level logger.setLevel(logging_level) - # Create console handler - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging_level) - - # Create formatter - formatter = logging.Formatter( - "%(asctime)s | %(levelname)s | %(name)s:%(funcName)s:%(lineno)d | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", + # Create Rich handler with VS Code compatible formatting in DEV_MODE + rich_handler = RichHandler( + console=console, + enable_link_path=get_settings().DEV_MODE, # Enable VS Code clickable links in DEV_MODE + markup=True, + rich_tracebacks=True, + tracebacks_show_locals=True, + show_time=True, + show_level=True, + show_path=True, ) + rich_handler.setLevel(logging_level) - # Add formatter to handler - console_handler.setFormatter(formatter) + # Add the Rich handler to the logger + logger.addHandler(rich_handler) - # Add handler to logger - logger.addHandler(console_handler) + # Add file handler if DEV_MODE is enabled + if get_settings().DEV_MODE: + file_handler = create_file_handler(name, logging_level) + logger.addHandler(file_handler) return logger diff --git a/src/ansari/app/main_stdio.py b/src/ansari/app/main_stdio.py index 4a79192..ae782bd 100644 --- a/src/ansari/app/main_stdio.py +++ b/src/ansari/app/main_stdio.py @@ -25,17 +25,27 @@ def main( input: Optional[str] = typer.Option( None, "--input", "-i", help="Input to send to the agent. If not provided, starts interactive mode." ), + stream: bool = typer.Option( + False, "--stream", "-s", help="Stream the output word by word. If False, prints the complete answer at once." + ), ): """ Run the Ansari agent. If input is provided, process it and exit. If no input is provided, start interactive mode. """ # Convert log level string to logging constant + # Get the root logger and set its level to match the CLI argument + # Note: We don't use logging.basicConfig() to avoid duplicate logs + # Our custom get_logger() function has already configured the loggers numeric_level = getattr(logging, log_level.upper(), None) if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level: {log_level}") - logging.basicConfig(level=numeric_level) + # Update the logger's level + logging.getLogger().setLevel(numeric_level) + # Also update our module's logger level + logger.setLevel(numeric_level) + settings = get_settings() if agent == "AnsariClaude": @@ -53,13 +63,19 @@ def main( result = agent_instance.process_input(input) # Handle the result which could be either a generator or other iterable if result: - for word in result: - if word is not None: + print("Model response:") + if stream: + # Stream output word by word + for word in result: print(word, end="", flush=True) - print() + print() + else: + # Collect the entire response and print at once + complete_response = "".join([word for word in result if word is not None]) + print(complete_response) else: # No input provided, start interactive mode - presenter = StdioPresenter(agent_instance, skip_greeting=True) + presenter = StdioPresenter(agent_instance, skip_greeting=True, stream=stream) presenter.present() diff --git a/src/ansari/presenters/stdio_presenter.py b/src/ansari/presenters/stdio_presenter.py index f8e65a7..8ba073c 100644 --- a/src/ansari/presenters/stdio_presenter.py +++ b/src/ansari/presenters/stdio_presenter.py @@ -4,9 +4,10 @@ class StdioPresenter: - def __init__(self, agent: Ansari, skip_greeting=False): + def __init__(self, agent: Ansari, skip_greeting=False, stream=False): self.agent = agent self.skip_greeting = skip_greeting + self.stream = stream def present(self): if not self.skip_greeting: @@ -18,10 +19,17 @@ def present(self): result = self.agent.process_input(inp) # Handle the result which could be either a generator or other iterable if result: - for word in result: - if word is not None: + print("Model response:") + if self.stream: + # Stream output word by word + for word in result: sys.stdout.write(word) sys.stdout.flush() + else: + # Collect the entire response and output at once + complete_response = "".join([word for word in result]) + sys.stdout.write(complete_response) + sys.stdout.flush() sys.stdout.write("\n> ") sys.stdout.flush() inp = sys.stdin.readline()