diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0c3326..ba4b5a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,10 +48,15 @@ cd backend python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt -cp .env.example .env # fill in HF_TOKEN at minimum; other keys are optional +cp .env.example .env # fill in HF_TOKEN at minimum; set LOG_LEVEL or LOG_FORMAT_JSON as needed uvicorn app.main:app --reload --port 8000 ``` +To adjust log verbosity locally or enable structured JSON output: +```bash +LOG_LEVEL=DEBUG LOG_FORMAT_JSON=true uvicorn app.main:app +``` + The backend degrades gracefully if optional services aren't configured — Pinecone (RAG), Supabase (query history), and Finnhub are all optional. `HF_TOKEN` is required for live LLM inference against the fine-tuned model; without it, `/verify` (DVL-only, no LLM call) still works. ### Frontend (Next.js) diff --git a/finverify-terminal/backend/.env.example b/finverify-terminal/backend/.env.example index 6e5055a..903c607 100644 --- a/finverify-terminal/backend/.env.example +++ b/finverify-terminal/backend/.env.example @@ -22,3 +22,9 @@ SUPABASE_KEY=your_anon_key_here # Pinecone (vector store for RAG pipeline) PINECONE_API_KEY=your_pinecone_api_key_here + +# Logging Configuration +LOG_LEVEL=INFO +LOG_FORMAT=text +LOG_FORMAT_JSON=false + diff --git a/finverify-terminal/backend/README.md b/finverify-terminal/backend/README.md index 645e478..4092e6b 100644 --- a/finverify-terminal/backend/README.md +++ b/finverify-terminal/backend/README.md @@ -41,3 +41,20 @@ Validated on FinQA dev set (n=873) achieving **42.61% accuracy** — a **42× im ## Model [aadi2026/finverify-lora](https://huggingface.co/aadi2026/finverify-lora) — Mistral-7B + QLoRA (4-bit NF4) + +## Logging Configuration + +Log verbosity and output formatting can be configured via environment variables: + +| Variable | Default | Values | Description | +|----------|---------|--------|-------------| +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | Sets logging verbosity level | +| `LOG_FORMAT` | `text` | `text`, `json` | Sets logging output format | +| `LOG_FORMAT_JSON` | `false` | `true`, `false` | Enables structured JSON output for log aggregators | + +Example running locally with DEBUG verbosity and JSON formatted output: + +```bash +LOG_LEVEL=DEBUG LOG_FORMAT_JSON=true uvicorn app.main:app +``` + diff --git a/finverify-terminal/backend/app/logging_config.py b/finverify-terminal/backend/app/logging_config.py new file mode 100644 index 0000000..cb93e2b --- /dev/null +++ b/finverify-terminal/backend/app/logging_config.py @@ -0,0 +1,129 @@ +""" +Logging Configuration +===================== +Centralized logging setup for FinVerify Terminal. +Supports standard formatted console logging and structured JSON logging. +Configurable via environment variables (LOG_LEVEL, LOG_FORMAT, LOG_FORMAT_JSON) +or explicit parameters passed to `setup_logging()`. +""" + +import os +import sys +import json +import logging +from datetime import datetime, timezone +from typing import Optional, Any +from pydantic import BaseModel, Field + + +class LoggingSettings(BaseModel): + """Logging settings schema backed by environment variables.""" + + log_level: str = Field( + default_factory=lambda: os.getenv("LOG_LEVEL", "INFO").upper() + ) + log_format: str = Field( + default_factory=lambda: os.getenv("LOG_FORMAT", "text").lower() + ) + log_format_json: bool = Field( + default_factory=lambda: os.getenv("LOG_FORMAT_JSON", "false").lower() in ("true", "1", "yes") + ) + + @property + def is_json(self) -> bool: + return self.log_format_json or self.log_format == "json" + + @property + def numeric_level(self) -> int: + return getattr(logging, self.log_level.upper(), logging.INFO) + + +class JSONFormatter(logging.Formatter): + """Formatter that outputs single-line JSON objects for log aggregation.""" + + def format(self, record: logging.LogRecord) -> str: + log_obj: dict[str, Any] = { + "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + if record.exc_info: + log_obj["exception"] = self.formatException(record.exc_info) + if record.stack_info: + log_obj["stack_info"] = self.formatStack(record.stack_info) + + # Include custom extra fields if passed in log call + standard_attrs = { + "name", "msg", "args", "levelname", "levelno", "pathname", "filename", + "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", + "created", "msecs", "relativeCreated", "thread", "threadName", + "processName", "process", "taskName", + } + for key, val in record.__dict__.items(): + if key not in standard_attrs and not key.startswith("_"): + try: + json.dumps(val) + log_obj[key] = val + except (TypeError, ValueError): + log_obj[key] = str(val) + + return json.dumps(log_obj) + + +def get_logging_settings() -> LoggingSettings: + """Instantiate logging settings from environment.""" + return LoggingSettings() + + +def setup_logging( + log_level: Optional[str] = None, + log_format: Optional[str] = None, + json_format: Optional[bool] = None, + stream: Optional[Any] = None, +) -> logging.Logger: + """ + Configure root logging with specified or environment-driven level and format. + + Args: + log_level: Logging level string ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'). + log_format: Logging format mode ('text' or 'json'). + json_format: Boolean flag to force JSON format. + stream: Target stream (defaults to sys.stdout). + + Returns: + Root logger instance. + """ + settings = get_logging_settings() + + # Override settings with explicit arguments if provided + effective_level_str = (log_level or settings.log_level).upper() + numeric_level = getattr(logging, effective_level_str, logging.INFO) + + use_json = json_format if json_format is not None else settings.is_json + if log_format is not None: + use_json = log_format.lower() == "json" or use_json + + root_logger = logging.getLogger() + root_logger.setLevel(numeric_level) + + # Remove existing handlers to avoid duplicate log messages (idempotent) + for handler in list(root_logger.handlers): + root_logger.removeHandler(handler) + + # Create stream handler + console_handler = logging.StreamHandler(stream or sys.stdout) + console_handler.setLevel(numeric_level) + + if use_json: + formatter: logging.Formatter = JSONFormatter() + else: + fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + datefmt = "%Y-%m-%dT%H:%M:%S%z" + formatter = logging.Formatter(fmt=fmt, datefmt=datefmt) + + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + return root_logger diff --git a/finverify-terminal/backend/app/main.py b/finverify-terminal/backend/app/main.py index 359e5a2..980877e 100644 --- a/finverify-terminal/backend/app/main.py +++ b/finverify-terminal/backend/app/main.py @@ -74,6 +74,14 @@ from core.financial import FinancialDocumentService load_dotenv() + +try: + from .logging_config import setup_logging +except ImportError: + from app.logging_config import setup_logging + +setup_logging() + logger = logging.getLogger(__name__) financial_document_service = FinancialDocumentService() diff --git a/finverify-terminal/backend/tests/test_logging_config.py b/finverify-terminal/backend/tests/test_logging_config.py new file mode 100644 index 0000000..03f49c8 --- /dev/null +++ b/finverify-terminal/backend/tests/test_logging_config.py @@ -0,0 +1,81 @@ +""" +Tests for Centralized Logging Configuration +============================================ +Verifies logging_config.py behavior: +- Standard format logging +- JSON structured format logging +- Environment variable configuration +- Idempotency of setup_logging() +""" + +import io +import json +import logging +import pytest +from app.logging_config import setup_logging, LoggingSettings, JSONFormatter + + +def test_default_setup_logging(): + """Verify default logging setup configures root logger.""" + stream = io.StringIO() + logger = setup_logging(log_level="INFO", json_format=False, stream=stream) + + assert logger.level == logging.INFO + test_logger = logging.getLogger("test_logger") + test_logger.info("Hello World") + + output = stream.getvalue() + assert "[INFO] test_logger: Hello World" in output + + +def test_json_formatting(): + """Verify JSON formatting outputs valid JSON with expected fields.""" + stream = io.StringIO() + setup_logging(log_level="DEBUG", json_format=True, stream=stream) + + test_logger = logging.getLogger("test_json") + test_logger.info("Test JSON log message", extra={"user_id": "usr_123"}) + + output = stream.getvalue().strip() + log_data = json.loads(output) + + assert log_data["level"] == "INFO" + assert log_data["logger"] == "test_json" + assert log_data["message"] == "Test JSON log message" + assert log_data["user_id"] == "usr_123" + assert "timestamp" in log_data + + +def test_log_level_filtering(): + """Verify log messages below set level are filtered out.""" + stream = io.StringIO() + setup_logging(log_level="WARNING", json_format=False, stream=stream) + + test_logger = logging.getLogger("test_filter") + test_logger.info("This should not be logged") + test_logger.warning("This should be logged") + + output = stream.getvalue() + assert "This should not be logged" not in output + assert "This should be logged" in output + + +def test_idempotent_setup_logging(): + """Verify repeated calls do not duplicate log handlers.""" + stream = io.StringIO() + setup_logging(log_level="INFO", json_format=False, stream=stream) + setup_logging(log_level="INFO", json_format=False, stream=stream) + + root_logger = logging.getLogger() + assert len(root_logger.handlers) == 1 + + +def test_env_var_configuration(monkeypatch): + """Verify environment variables dictate logging settings.""" + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + monkeypatch.setenv("LOG_FORMAT_JSON", "true") + + settings = LoggingSettings() + assert settings.log_level == "DEBUG" + assert settings.is_json is True + assert settings.numeric_level == logging.DEBUG