Skip to content
Open
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
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions finverify-terminal/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

17 changes: 17 additions & 0 deletions finverify-terminal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

129 changes: 129 additions & 0 deletions finverify-terminal/backend/app/logging_config.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions finverify-terminal/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
81 changes: 81 additions & 0 deletions finverify-terminal/backend/tests/test_logging_config.py
Original file line number Diff line number Diff line change
@@ -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