10 Commits

25 changed files with 609 additions and 492 deletions

View File

@@ -41,7 +41,8 @@ docs/
*.md
!README.md
# Data (will be mounted as volumes)
# Data
data/
memory_data/
logs/
*.log

View File

@@ -1,18 +1,43 @@
# API Keys
# Deepseek API Key (for LLM in alfred)
# Configuration
LIBRECHAT_VERSION=v0.8.1
RAG_VERSION=v0.7.0
# Keys
# - Deepseek API
DEEPSEEK_API_KEY=
# - Google API
GOOGLE_API_KEY=
#GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite #TODO:Update models
# - Anthropic API
ANTHROPIC_API_KEY=
# - Kimi API
KIMI_API_KEY=
# - ChatGPT/Open API
OPENAI_API_KEY=
# - Themoviedb.org API (media metadata)
TMDB_API_KEY=
# - Security keys
JWT_SECRET=
JWT_REFRESH_SECRET=
CREDS_KEY=
CREDS_IV=
# Local LLM
OLLAMA_BASE_URL=
OLLAMA_MODEL=
# Alfred Configuration
# LLM Provider (deepseek or ollama)
LLM_PROVIDER=deepseek
# Memory storage directory (inside container)
MEMORY_STORAGE_DIR=/data/memory
# External Services (Optional)
# TMDB API Key (for movie metadata)
TMDB_API_KEY=
# qBittorrent Configuration
QBITTORRENT_URL=
QBITTORRENT_USERNAME=admin
@@ -21,3 +46,8 @@ QBITTORRENT_PASSWORD=adminadmin
# Debug Options
DEBUG_LOGGING=false
DEBUG_CONSOLE=false
# Postgres (RAG)
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=

401
Makefile
View File

@@ -1,245 +1,155 @@
.POSIX:
.SUFFIXES:
.DEFAULT_GOAL := help
# --- SETTINGS ---
IMAGE_NAME = alfred_media_organizer
# renovate: datasource=docker depName=python
PYTHON_VERSION = $(shell grep "python" pyproject.toml | head -n 1 | sed -E 's/.*[=<>^~"]+ *([0-9]+\.[0-9]+(\.[0-9]+)?).*/\1/')
PYTHON_VERSION_SHORT = $(shell echo $(PYTHON_VERSION) | cut -d. -f1,2)
# Change to 'uv' when ready.
RUNNER ?= poetry
SERVICE_NAME = alfred
# --- Config ---
export IMAGE_NAME := alfred_media_organizer
export LIBRECHAT_VERSION := v0.8.1
export PYTHON_VERSION := 3.14.2
export PYTHON_VERSION_SHORT := 3.14
export RAG_VERSION := v0.7.0
export RUNNER := poetry
export SERVICE_NAME := alfred
export IMAGE_NAME
export PYTHON_VERSION
export PYTHON_VERSION_SHORT
export RUNNER
# --- ADAPTERS ---
# UV uses "sync", Poetry uses "install". Both install DEV deps by default.
INSTALL_CMD = $(if $(filter uv,$(RUNNER)),sync,install)
# --- MACROS ---
ARGS = $(filter-out $@,$(MAKECMDGOALS))
BUMP_CMD = $(RUNNER) run bump-my-version bump
COMPOSE_CMD = docker-compose
DOCKER_CMD = docker build \
# --- Commands ---
CLI := python3 cli.py
DOCKER_COMPOSE := docker compose
DOCKER_BUILD := docker build \
--build-arg PYTHON_VERSION=$(PYTHON_VERSION) \
--build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \
--build-arg RUNNER=$(RUNNER) \
-t $(IMAGE_NAME):latest .
--build-arg RUNNER=$(RUNNER)
RUNNER_ADD = $(RUNNER) add
RUNNER_HOOKS = $(RUNNER) run pre-commit install -c ../.pre-commit-config.yaml
RUNNER_INSTALL = $(RUNNER) $(INSTALL_CMD)
RUNNER_RUN = $(RUNNER) run
RUNNER_UPDATE = $(RUNNER) update
# --- Phony ---
.PHONY: setup status check
.PHONY: up down restart logs ps shell
.PHONY: build build-test
.PHONY: install update install-hooks
.PHONY: test coverage lint format clean prune
.PHONY: major minor patch
.PHONY: help
# --- STYLES ---
B = \033[1m
G = \033[32m
T = \033[36m
R = \033[0m
# --- Setup ---
setup:
@echo "Initializing environment..."
@$(CLI) setup \
&& echo "✓ Environment ready" \
|| (echo "✗ Setup failed" && exit 1)
# --- TARGETS ---
.PHONY: add build build-test check-docker check-runner clean coverage down format help init-dotenv install install-hooks lint logs major minor patch prune ps python-version restart run shell test up update _check_branch _ci-dump-config _ci-run-tests _push_tag
status:
@$(CLI) status
# Catch-all for args
%:
@:
check:
@$(CLI) check
add: check-runner
@echo "$(T) Adding dependency ($(RUNNER)): $(ARGS)$(R)"
$(RUNNER_ADD) $(ARGS)
# --- Docker ---
up: check
@echo "Starting containers..."
@$(DOCKER_COMPOSE) up -d --remove-orphans \
&& echo "✓ Containers started" \
|| (echo "✗ Failed to start containers" && exit 1)
build: check-docker
@echo "$(T)🐳 Building Docker image...$(R)"
$(DOCKER_CMD)
@echo "✅ Image $(IMAGE_NAME):latest ready."
down:
@echo "Stopping containers..."
@$(DOCKER_COMPOSE) down \
&& echo "✓ Containers stopped" \
|| (echo "✗ Failed to stop containers" && exit 1)
build-test: check-docker
@echo "$(T)🐳 Building test image (with dev deps)...$(R)"
docker build \
--build-arg RUNNER=$(RUNNER) \
--build-arg PYTHON_VERSION=$(PYTHON_VERSION) \
--build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \
--target test \
-t $(IMAGE_NAME):test .
@echo "✅ Test image $(IMAGE_NAME):test ready."
restart:
@echo "Restarting containers..."
@$(DOCKER_COMPOSE) restart \
&& echo "✓ Containers restarted" \
|| (echo "✗ Failed to restart containers" && exit 1)
check-docker:
@command -v docker >/dev/null 2>&1 || { echo "$(R)❌ Docker not installed$(R)"; exit 1; }
@docker info >/dev/null 2>&1 || { echo "$(R)❌ Docker daemon not running$(R)"; exit 1; }
logs:
@echo "Following logs (Ctrl+C to exit)..."
@$(DOCKER_COMPOSE) logs -f
check-runner:
@command -v $(RUNNER) >/dev/null 2>&1 || { echo "$(R)$(RUNNER) not installed$(R)"; exit 1; }
ps:
@echo "Container status:"
@$(DOCKER_COMPOSE) ps
shell:
@echo "Opening shell in $(SERVICE_NAME)..."
@$(DOCKER_COMPOSE) exec $(SERVICE_NAME) /bin/bash
# --- Build ---
build: check
@echo "Building image $(IMAGE_NAME):latest ..."
@$(DOCKER_BUILD) -t $(IMAGE_NAME):latest . \
&& echo "✓ Build complete" \
|| (echo "✗ Build failed" && exit 1)
build-test: check
@echo "Building test image $(IMAGE_NAME):test..."
@$(DOCKER_BUILD) --target test -t $(IMAGE_NAME):test . \
&& echo "✓ Test image built" \
|| (echo "✗ Build failed" && exit 1)
# --- Dependencies ---
install:
@echo "Installing dependencies with $(RUNNER)..."
@$(RUNNER) install \
&& echo "✓ Dependencies installed" \
|| (echo "✗ Installation failed" && exit 1)
update:
@echo "Updating dependencies with $(RUNNER)..."
@$(RUNNER) update \
&& echo "✓ Dependencies updated" \
|| (echo "✗ Update failed" && exit 1)
install-hooks:
@echo "Installing pre-commit hooks..."
@$(RUNNER) run pre-commit install \
&& echo "✓ Hooks installed" \
|| (echo "✗ Hook installation failed" && exit 1)
# --- Quality ---
test:
@echo "Running tests..."
@$(RUNNER) run pytest \
&& echo "✓ Tests passed" \
|| (echo "✗ Tests failed" && exit 1)
coverage:
@echo "Running tests with coverage..."
@$(RUNNER) run pytest --cov=. --cov-report=html --cov-report=term \
&& echo "✓ Coverage report generated" \
|| (echo "✗ Coverage failed" && exit 1)
lint:
@echo "Linting code..."
@$(RUNNER) run ruff check --fix . \
&& echo "✓ Linting complete" \
|| (echo "✗ Linting failed" && exit 1)
format:
@echo "Formatting code..."
@$(RUNNER) run ruff format . && $(RUNNER) run ruff check --fix . \
&& echo "✓ Code formatted" \
|| (echo "✗ Formatting failed" && exit 1)
clean:
@echo "$(T)🧹 Cleaning caches...$(R)"
rm -rf .ruff_cache __pycache__ .pytest_cache
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
@echo "✅ Caches cleaned."
@echo "Cleaning build artifacts..."
@rm -rf .ruff_cache __pycache__ .pytest_cache htmlcov .coverage
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@echo "✓ Cleanup complete"
coverage: check-runner
@echo "$(T)📊 Running tests with coverage...$(R)"
$(RUNNER_RUN) pytest --cov=. --cov-report=html --cov-report=term $(ARGS)
@echo "✅ Report generated in htmlcov/"
prune:
@echo "Pruning Docker system..."
@docker system prune -af \
&& echo "✓ Docker pruned" \
|| (echo "✗ Prune failed" && exit 1)
down: check-docker
@echo "$(T)🛑 Stopping containers...$(R)"
$(COMPOSE_CMD) down
@echo "✅ System stopped."
# --- Versioning ---
major minor patch: _check-main
@echo "Bumping $@ version..."
@$(RUNNER) run bump-my-version bump $@ \
&& echo "✓ Version bumped" \
|| (echo "✗ Version bump failed" && exit 1)
format: check-runner
@echo "$(T)✨ Formatting with Ruff...$(R)"
$(RUNNER_RUN) ruff format .
$(RUNNER_RUN) ruff check --fix .
@echo "✅ Code cleaned."
help:
@echo "$(B)Available commands:$(R)"
@echo ""
@echo "$(G)Setup:$(R)"
@echo " $(T)check-docker $(R) Verify Docker is installed and running."
@echo " $(T)check-runner $(R) Verify package manager ($(RUNNER))."
@echo " $(T)init-dotenv $(R) Create .env from .env.example with generated secrets."
@echo " $(T)install $(R) Install ALL dependencies (Prod + Dev)."
@echo " $(T)install-hooks $(R) Install git pre-commit hooks."
@echo ""
@echo "$(G)Docker:$(R)"
@echo " $(T)build $(R) Build the docker image (production)."
@echo " $(T)build-test $(R) Build the docker image (with dev deps for testing)."
@echo " $(T)down $(R) Stop and remove containers."
@echo " $(T)logs $(R) Follow logs."
@echo " $(T)prune $(R) Clean Docker system."
@echo " $(T)ps $(R) Show container status."
@echo " $(T)restart $(R) Restart all containers."
@echo " $(T)shell $(R) Open shell in container."
@echo " $(T)up $(R) Start the agent."
@echo ""
@echo "$(G)Development:$(R)"
@echo " $(T)add ... $(R) Add dependency (use --group dev or --dev if needed)."
@echo " $(T)clean $(R) Clean caches."
@echo " $(T)coverage $(R) Run tests with coverage."
@echo " $(T)format $(R) Format code (Ruff)."
@echo " $(T)lint $(R) Lint code without fixing."
@echo " $(T)test ... $(R) Run tests (local with $(RUNNER))."
@echo " $(T)update $(R) Update dependencies."
@echo ""
@echo "$(G)Versioning:$(R)"
@echo " $(T)major/minor/patch $(R) Bump version and push tag (triggers CI/CD)."
init-dotenv:
@echo "$(T)🔑 Initializing .env file...$(R)"
@if [ -f .env ]; then \
echo "$(R)⚠️ .env already exists. Skipping.$(R)"; \
exit 0; \
fi
@if [ ! -f .env.example ]; then \
echo "$(R)❌ .env.example not found$(R)"; \
exit 1; \
fi
@if ! command -v openssl >/dev/null 2>&1; then \
echo "$(R)❌ openssl not found. Please install it first.$(R)"; \
exit 1; \
fi
@echo "$(T) → Copying .env.example...$(R)"
@cp .env.example .env
@echo "$(T) → Generating secrets...$(R)"
@sed -i.bak "s|JWT_SECRET=.*|JWT_SECRET=$$(openssl rand -base64 32)|" .env
@sed -i.bak "s|JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$$(openssl rand -base64 32)|" .env
@sed -i.bak "s|CREDS_KEY=.*|CREDS_KEY=$$(openssl rand -hex 16)|" .env
@sed -i.bak "s|CREDS_IV=.*|CREDS_IV=$$(openssl rand -hex 8)|" .env
@sed -i.bak "s|MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$$(openssl rand -base64 32)|" .env
@rm -f .env.bak
@echo "$(G)✅ .env created with generated secrets!$(R)"
@echo "$(T)⚠️ Don't forget to add your API keys:$(R)"
@echo " - OPENAI_API_KEY"
@echo " - DEEPSEEK_API_KEY"
@echo " - TMDB_API_KEY (optional)"
install: check-runner
@echo "$(T)📦 Installing FULL environment ($(RUNNER))...$(R)"
$(RUNNER_INSTALL)
@echo "✅ Environment ready (Prod + Dev)."
install-hooks: check-runner
@echo "$(T)🔧 Installing hooks...$(R)"
$(RUNNER_HOOKS)
@echo "✅ Hooks ready."
lint: check-runner
@echo "$(T)🔍 Linting code...$(R)"
$(RUNNER_RUN) ruff check .
logs: check-docker
@echo "$(T)📋 Following logs...$(R)"
$(COMPOSE_CMD) logs -f
major: _check_branch
@echo "$(T)💥 Bumping major...$(R)"
SKIP=all $(BUMP_CMD) major
@$(MAKE) -s _push_tag
minor: _check_branch
@echo "$(T)✨ Bumping minor...$(R)"
SKIP=all $(BUMP_CMD) minor
@$(MAKE) -s _push_tag
patch: _check_branch
@echo "$(T)🚀 Bumping patch...$(R)"
SKIP=all $(BUMP_CMD) patch
@$(MAKE) -s _push_tag
prune: check-docker
@echo "$(T)🗑️ Pruning Docker resources...$(R)"
docker system prune -af
@echo "✅ Docker cleaned."
ps: check-docker
@echo "$(T)📋 Container status:$(R)"
@$(COMPOSE_CMD) ps
python-version:
@echo "🔍 Reading pyproject.toml..."
@echo "✅ Python version : $(PYTHON_VERSION)"
@echo " Sera utilisé pour : FROM python:$(PYTHON_VERSION)-slim"
restart: check-docker
@echo "$(T)🔄 Restarting containers...$(R)"
$(COMPOSE_CMD) restart
@echo "✅ Containers restarted."
run: check-runner
$(RUNNER_RUN) $(ARGS)
shell: check-docker
@echo "$(T)🐚 Opening shell in $(SERVICE_NAME)...$(R)"
$(COMPOSE_CMD) exec $(SERVICE_NAME) /bin/sh
test: check-runner
@echo "$(T)🧪 Running tests...$(R)"
$(RUNNER_RUN) pytest $(ARGS)
up: check-docker
@echo "$(T)🚀 Starting Agent Media...$(R)"
$(COMPOSE_CMD) up -d
@echo "✅ System is up."
update: check-runner
@echo "$(T)🔄 Updating dependencies...$(R)"
$(RUNNER_UPDATE)
@echo "✅ All packages up to date."
_check_branch:
@curr=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$curr" != "main" ]; then \
echo "❌ Error: not on the main branch"; exit 1; \
fi
@echo "Pushing tags..."
@git push --tags \
&& echo "✓ Tags pushed" \
|| (echo "✗ Push failed" && exit 1)
_ci-dump-config:
@echo "image_name=$(IMAGE_NAME)"
@@ -248,15 +158,46 @@ _ci-dump-config:
@echo "runner=$(RUNNER)"
@echo "service_name=$(SERVICE_NAME)"
_ci-run-tests: build-test
@echo "$(T)🧪 Running tests in Docker...$(R)"
_ci-run-tests:
@echo "Running tests in Docker..."
docker run --rm \
-e DEEPSEEK_API_KEY \
-e TMDB_API_KEY \
-e QBITTORRENT_URL \
$(IMAGE_NAME):test pytest
@echo " Tests passed."
@echo " Tests passed."
_push_tag:
@echo "$(T)📦 Pushing tag...$(R)"
git push --tags
@echo "✅ Tag pushed. Check CI for build status."
_check-main:
@test "$$(git rev-parse --abbrev-ref HEAD)" = "main" \
|| (echo "✗ ERROR: Not on main branch" && exit 1)
# --- Help ---
help:
@echo "Usage: make [target]"
@echo ""
@echo "Setup:"
@echo " setup Initialize .env"
@echo " status Show project status"
@echo ""
@echo "Docker:"
@echo " up Start containers"
@echo " down Stop containers"
@echo " restart Restart containers"
@echo " logs Follow logs"
@echo " ps Container status"
@echo " shell Shell into container"
@echo " build Build image"
@echo ""
@echo "Dev:"
@echo " install Install dependencies"
@echo " update Update dependencies"
@echo " test Run tests"
@echo " coverage Run tests with coverage"
@echo " lint Lint code"
@echo " format Format code"
@echo " clean Clean artifacts"
@echo ""
@echo "Release:"
@echo " patch Bump patch version"
@echo " minor Bump minor version"
@echo " major Bump major version"

View File

@@ -5,11 +5,12 @@ import logging
import os
import time
import uuid
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, validator
from typing import Any
from alfred.agent.agent import Agent
from alfred.agent.config import settings
@@ -40,7 +41,6 @@ else:
)
# Initialize memory context at startup
# Use /data/memory in Docker, fallback to memory_data for local dev
storage_dir = os.getenv("MEMORY_STORAGE_DIR", "memory_data")
init_memory(storage_dir=storage_dir)
logger.info(f"Memory context initialized (storage: {storage_dir})")

View File

@@ -2,7 +2,11 @@
import logging
from alfred.infrastructure.api.knaben import KnabenAPIError, KnabenClient, KnabenNotFoundError
from alfred.infrastructure.api.knaben import (
KnabenAPIError,
KnabenClient,
KnabenNotFoundError,
)
from .dto import SearchTorrentsResponse

View File

@@ -1,12 +1,13 @@
"""Knaben torrent search API client."""
import logging
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.agent.config import Settings, settings
from .dto import TorrentResult
from .exceptions import KnabenAPIError, KnabenNotFoundError

View File

@@ -1,11 +1,13 @@
"""qBittorrent Web API client."""
import logging
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.agent.config import Settings, settings
from .dto import TorrentInfo
from .exceptions import QBittorrentAPIError, QBittorrentAuthError

View File

@@ -1,12 +1,13 @@
"""TMDB (The Movie Database) API client."""
import logging
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from typing import Any
import requests
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.agent.config import Settings, settings
from .dto import MediaResult
from .exceptions import (
TMDBAPIError,

231
cli.py Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
import os
import secrets
import shutil
import subprocess
import sys
from datetime import datetime
from enum import StrEnum
from pathlib import Path
from typing import NoReturn
REQUIRED_VARS = ["DEEPSEEK_API_KEY", "TMDB_API_KEY", "QBITTORRENT_URL"]
# Size in bytes
KEYS_TO_GENERATE = {
"JWT_SECRET": 32,
"JWT_REFRESH_SECRET": 32,
"CREDS_KEY": 32,
"CREDS_IV": 16,
}
class Style(StrEnum):
"""ANSI codes for styling output.
Usage: f"{Style.RED}Error{Style.RESET}"
"""
RESET = "\033[0m"
BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
DIM = "\033[2m"
# Only for terminals and if not specified otherwise
USE_COLORS = sys.stdout.isatty() and "NO_COLOR" not in os.environ
def styled(text: str, color_code: str) -> str:
"""Apply color only if supported by the terminal."""
if USE_COLORS:
return f"{color_code}{text}{Style.RESET}"
return text
def log(msg: str, color: str | None = None, prefix="") -> None:
"""Print a formatted message."""
formatted_msg = styled(msg, color) if color else msg
print(f"{prefix}{formatted_msg}")
def error_exit(msg: str) -> NoReturn:
"""Print an error message in red and exit."""
log(f"{msg}", Style.RED)
sys.exit(1)
def is_docker_running() -> bool:
""" "Check if Docker is available and responsive."""
if shutil.which("docker") is None:
error_exit("Docker is not installed.")
result = subprocess.run(
["docker", "info"],
# Redirect stdout/stderr to keep output clean on success
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
# Prevent exception being raised
check=False,
)
return result.returncode == 0
def parse_env(content: str) -> dict[str, str]:
"""Parses existing keys and values into a dict (ignoring comments)."""
env_vars = {}
for raw_line in content.splitlines():
line = raw_line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
env_vars[key.strip()] = value.strip()
return env_vars
def dump_env(content: str, data: dict[str, str]) -> str:
new_content: list[str] = []
processed_keys = set()
for raw_line in content.splitlines():
line = raw_line.strip()
# Fast line (empty, comment or not an assignation)
if len(line) == 0 or line.startswith("#") or "=" not in line:
new_content.append(raw_line)
continue
# Slow line (inline comment to be kept)
key_chunk, value_chunk = raw_line.split("=", 1)
key = key_chunk.strip()
# Not in the update list
if key not in data:
new_content.append(raw_line)
continue
processed_keys.add(key)
new_value = data[key]
if " #" not in value_chunk:
new_line = f"{key_chunk}={new_value}"
else:
_, comment = value_chunk.split(" #", 1)
new_line = f"{key_chunk}={new_value} #{comment}"
new_content.append(new_line)
for key, value in data.items():
if key not in processed_keys:
new_content.append(f"{key}={value}")
return "\n".join(new_content) + "\n"
def ensure_env() -> None:
"""Manage .env lifecycle: creation, secret generation, prompts."""
env_path = Path(".env")
env_example_path = Path(".env.example")
updated: bool = False
# Read .env if exists
if env_path.exists():
content: str = env_path.read_text(encoding="utf-8")
else:
content: str = env_example_path.read_text(encoding="utf-8")
existing_vars: dict[str, str] = parse_env(content)
# Generate missing secrets
for key, length in KEYS_TO_GENERATE.items():
if key not in existing_vars or not existing_vars[key]:
log(f"Generating {key}...", Style.GREEN, prefix=" ")
existing_vars[key] = secrets.token_hex(length)
updated = True
log("Done", Style.GREEN, prefix=" ")
# Prompt for missing mandatory keys
color = Style.YELLOW if USE_COLORS else ""
reset = Style.RESET if USE_COLORS else ""
for key in REQUIRED_VARS:
if key not in existing_vars or not existing_vars[key]:
try:
existing_vars[key] = input(
f" {color}Enter value for {key}: {reset}"
).strip()
updated = True
except KeyboardInterrupt:
print()
error_exit("Aborted by user.")
# Write to disk
if updated:
# But backup original first
if env_path.exists():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = Path(f"{env_path}.{timestamp}.bak")
shutil.copy(env_path, backup_path)
log(f"Backup created: {backup_path}", Style.DIM)
new_content = dump_env(content, existing_vars)
env_path.write_text(new_content, encoding="utf-8")
log(".env updated successfully.", Style.GREEN)
else:
log("Configuration is up to date.", Style.GREEN)
def setup() -> None:
"""Orchestrate initialization."""
is_docker_running()
ensure_env()
def status() -> None:
"""Display simple dashboard."""
# Hardcoded bold style for title if colors are enabled
title_style = Style.BOLD if USE_COLORS else ""
reset_style = Style.RESET if USE_COLORS else ""
print(f"\n{title_style}ALFRED STATUS{reset_style}")
print(f"{title_style}==============={reset_style}\n")
# Docker Check
if is_docker_running():
print(f" Docker: {styled('✓ running', Style.GREEN)}")
else:
print(f" Docker: {styled('✗ stopped', Style.RED)}")
# Env Check
if Path(".env").exists():
print(f" .env: {styled('✓ present', Style.GREEN)}")
else:
print(f" .env: {styled('✗ missing', Style.RED)}")
print("")
def check() -> None:
"""Silent check for prerequisites (used by 'make up')."""
setup()
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python cli.py [setup|check|status]")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "setup":
setup()
elif cmd == "check":
check()
elif cmd == "status":
status()
else:
error_exit(f"Unknown command: {cmd}")
if __name__ == "__main__":
main()

102
docker-compose.yaml Normal file
View File

@@ -0,0 +1,102 @@
services:
alfred:
container_name: alfred-core
build:
context: .
args:
PYTHON_VERSION: ${PYTHON_VERSION}
PYTHON_VERSION_SHORT: ${PYTHON_VERSION_SHORT}
RUNNER: ${RUNNER}
depends_on:
- librechat
restart: unless-stopped
env_file:
- .env
environment:
# LLM Configuration
LLM_PROVIDER: ${LLM_PROVIDER:-deepseek}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
# Memory storage
MEMORY_STORAGE_DIR: /data/memory
# External services
TMDB_API_KEY: ${TMDB_API_KEY:-}
QBITTORRENT_URL: ${QBITTORRENT_URL:-}
QBITTORRENT_USERNAME: ${QBITTORRENT_USERNAME:-}
QBITTORRENT_PASSWORD: ${QBITTORRENT_PASSWORD:-}
volumes:
- ./data/memory:/data/memory
- ./logs:/data/logs
# TODO: Development: mount code for hot reload (comment out in production)
# - ./alfred:/app/alfred
librechat:
container_name: alfred-librechat
image: ghcr.io/danny-avila/librechat:${LIBRECHAT_VERSION}
depends_on:
- mongodb
- meilisearch
- rag_api
restart: unless-stopped
env_file:
- .env
environment:
- HOST=0.0.0.0
- MONGO_URI=mongodb://mongodb:27017/LibreChat
- MEILI_HOST=http://meilisearch:7700
- RAG_PORT=${RAG_PORT:-8000}
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
ports:
- "${LIBRECHAT_PORT:-3080}:3080"
volumes:
- ./data/librechat/images:/app/client/public/images
- ./data/librechat/uploads:/app/client/uploads
- ./logs:/app/api/logs
# Mount custom endpoint
- ./librechat/manifests:/app/manifests:ro
- ./librechat/librechat.yaml:/app/librechat.yaml:ro
mongodb:
container_name: alfred-mongodb
image: mongo:latest
restart: unless-stopped
volumes:
- ./data/mongo:/data/db
command: mongod --noauth
meilisearch:
container_name: alfred-meilisearch
image: getmeili/meilisearch:v1.12.3
restart: unless-stopped
environment:
- MEILI_NO_ANALYTICS=true
volumes:
- ./data/meili:/meili_data
#profiles: ["meili", "full"]
rag_api:
container_name: alfred-rag
image: ghcr.io/danny-avila/librechat-rag-api-dev-lite:${RAG_VERSION}
restart: unless-stopped
env_file:
- .env
environment:
- DB_HOST=vectordb
- DB_PORT=5432
- RAG_PORT=${RAG_PORT:-8000}
ports:
- "${RAG_PORT:-8000}:${RAG_PORT:-8000}"
#profiles: ["rag", "full"]
vectordb:
container_name: alfred-vectordb
image: pgvector/pgvector:0.8.0-pg16-bookworm
restart: unless-stopped
env_file:
- .env
ports:
- "${VECTOR_DB_PORT:-5432}:5432"
volumes:
- ./data/vectordb:/var/lib/postgresql/data
#profiles: ["rag", "full"]

View File

@@ -1,202 +0,0 @@
version: "3.4"
services:
alfred:
build:
context: .
args:
RUNNER: ${RUNNER} # Get it from Makefile
container_name: alfred
restart: unless-stopped
env_file: .env
ports:
- "8000:8000"
volumes:
# Persistent data volumes (outside container /app)
- agent-memory:/data/memory
- agent-logs:/data/logs
# Development: mount code for hot reload (comment out in production)
# - ./alfred:/app
environment:
# LLM Configuration
LLM_PROVIDER: ${LLM_PROVIDER:-deepseek}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
# Memory storage
MEMORY_STORAGE_DIR: /data/memory
# External services
TMDB_API_KEY: ${TMDB_API_KEY:-}
QBITTORRENT_URL: ${QBITTORRENT_URL:-}
QBITTORRENT_USERNAME: ${QBITTORRENT_USERNAME:-}
QBITTORRENT_PASSWORD: ${QBITTORRENT_PASSWORD:-}
networks:
- agent-network
# Da face (LibreChat)
librechat:
image: ghcr.io/danny-avila/librechat-dev:latest
container_name: librechat-frontend
restart: unless-stopped
ports:
- "3080:3080"
depends_on:
- mongodb
- meilisearch
- rag_api
- alfred
env_file: .env
environment:
# MongoDB connection (no auth, matching LibreChat default)
MONGO_URI: mongodb://mongodb:27017/LibreChat
# App configuration
HOST: 0.0.0.0
PORT: 3080
# Security
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-this-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-your-super-secret-refresh-key-change-this-too}
CREDS_KEY: ${CREDS_KEY:-your-32-character-secret-key-here}
CREDS_IV: ${CREDS_IV:-your-16-character-iv-here}
# Session
SESSION_EXPIRY: ${SESSION_EXPIRY:-1000 * 60 * 15}
REFRESH_TOKEN_EXPIRY: ${REFRESH_TOKEN_EXPIRY:-1000 * 60 * 60 * 24 * 7}
# Domain
DOMAIN_CLIENT: ${DOMAIN_CLIENT:-http://localhost:3080}
DOMAIN_SERVER: ${DOMAIN_SERVER:-http://localhost:3080}
# Meilisearch
MEILI_HOST: http://meilisearch:7700
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFU}
# RAG API
RAG_API_URL: http://rag_api:8000
# Endpoints
ENDPOINTS: custom
# Debug (optional)
DEBUG_LOGGING: ${DEBUG_LOGGING:-false}
DEBUG_CONSOLE: ${DEBUG_CONSOLE:-false}
volumes:
- ./librechat/librechat.yaml:/app/librechat.yaml:ro
- librechat-images:/app/client/public/images
- librechat-logs:/app/api/logs
networks:
- agent-network
# MongoDB for LibreChat
mongodb:
image: mongo:latest
container_name: librechat-mongodb
restart: unless-stopped
volumes:
- mongodb-data:/data/db
command: mongod --noauth
ports:
- "27017:27017"
networks:
- agent-network
# Meilisearch - Search engine for LibreChat
#TODO: Follow currently used version on librechat's github
meilisearch:
image: getmeili/meilisearch:v1.12.3
container_name: librechat-meilisearch
restart: unless-stopped
volumes:
- meilisearch-data:/meili_data
environment:
MEILI_HOST: http://meilisearch:7700
MEILI_HTTP_ADDR: meilisearch:7700
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFU}
ports:
- "7700:7700"
networks:
- agent-network
# PostgreSQL with pgvector for RAG API
pgvector:
image: ankane/pgvector:latest
container_name: librechat-pgvector
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-librechat_rag}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- pgvector-data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- agent-network
# RAG API - Vector database for LibreChat
rag_api:
image: ghcr.io/danny-avila/librechat-rag-api-dev-lite:latest
container_name: librechat-rag-api
restart: unless-stopped
depends_on:
- pgvector
environment:
PORT: 8000
HOST: 0.0.0.0
# PostgreSQL connection (multiple variable names for compatibility)
DB_HOST: pgvector
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB:-librechat_rag}
DB_USER: ${POSTGRES_USER:-postgres}
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-librechat_rag}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
# RAG configuration
COLLECTION_NAME: ${RAG_COLLECTION_NAME:-testcollection}
EMBEDDINGS_PROVIDER: ${RAG_EMBEDDINGS_PROVIDER:-openai}
EMBEDDINGS_MODEL: ${RAG_EMBEDDINGS_MODEL:-text-embedding-3-small}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
RAG_UPLOAD_DIR: /app/uploads
volumes:
- rag-uploads:/app/uploads
ports:
- "8001:8000"
networks:
- agent-network
# Named volumes for persistent data
volumes:
# MongoDB data
mongodb-data:
driver: local
# Meilisearch data
meilisearch-data:
driver: local
# PostgreSQL pgvector data
pgvector-data:
driver: local
# RAG API uploads
rag-uploads:
driver: local
# LibreChat data
librechat-images:
driver: local
librechat-logs:
driver: local
# Alfred data
agent-memory:
driver: local
agent-logs:
driver: local
# Network for inter-service communication
networks:
agent-network:
driver: bridge

View File

@@ -1,27 +0,0 @@
#!/bin/bash
# Script to generate secure keys for LibreChat
# Run this script to generate random secure keys for your .env file
echo "==================================="
echo "LibreChat Security Keys Generator"
echo "==================================="
echo ""
echo "# MongoDB Password"
echo "MONGO_PASSWORD=$(openssl rand -base64 24)"
echo ""
echo "# JWT Secrets"
echo "JWT_SECRET=$(openssl rand -base64 32)"
echo "JWT_REFRESH_SECRET=$(openssl rand -base64 32)"
echo ""
echo "# Credentials Encryption Keys"
echo "CREDS_KEY=$(openssl rand -hex 16)"
echo "CREDS_IV=$(openssl rand -hex 8)"
echo ""
echo "==================================="
echo "Copy these values to your .env file"
echo "==================================="

View File

@@ -6,6 +6,13 @@ authors = ["Francwa <francois.hodiaumont@gmail.com>"]
readme = "README.md"
package-mode = false
[tool.alfred]
image_name = "alfred_media_organizer"
librechat_version = "v0.8.1"
rag_version = "v0.7.0"
runner = "poetry"
service_name = "alfred"
[tool.poetry.dependencies]
python = "==3.14.2"
python-dotenv = "^1.0.0"

View File

@@ -3,13 +3,14 @@
# TODO: Moved directory, should not be necessary anymore but need to check !!
# Ajouter le dossier parent (brain) au PYTHONPATH
# sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
import shutil
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, Mock
import pytest
from alfred.infrastructure.persistence import Memory, set_memory
@@ -21,6 +22,16 @@ def temp_dir():
shutil.rmtree(dirpath)
@pytest.fixture(autouse=True)
def mock_memory_storage_dir(monkeypatch):
"""Override MEMORY_STORAGE_DIR for all tests to use a temp directory."""
test_dir = tempfile.mkdtemp()
monkeypatch.setenv("MEMORY_STORAGE_DIR", test_dir)
yield
# Cleanup
shutil.rmtree(test_dir, ignore_errors=True)
@pytest.fixture
def memory(temp_dir):
"""Create a fresh Memory instance for testing."""

View File

@@ -1,7 +1,9 @@
"""Edge case tests for the Agent."""
import pytest
from unittest.mock import Mock
import pytest
from alfred.agent.agent import Agent
from alfred.infrastructure.persistence import get_memory

View File

@@ -10,8 +10,8 @@ class TestChatCompletionsEdgeCases:
def test_very_long_message(self, memory):
"""Should handle very long user message."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
# Patch the agent's LLM directly
mock_llm = Mock()
@@ -33,8 +33,8 @@ class TestChatCompletionsEdgeCases:
def test_unicode_message(self, memory):
"""Should handle unicode in message."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
mock_llm = Mock()
mock_llm.complete.return_value = {
@@ -59,8 +59,8 @@ class TestChatCompletionsEdgeCases:
def test_special_characters_in_message(self, memory):
"""Should handle special characters."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
mock_llm = Mock()
mock_llm.complete.return_value = {"role": "assistant", "content": "Response"}
@@ -187,8 +187,8 @@ class TestChatCompletionsEdgeCases:
def test_many_messages(self, memory):
"""Should handle many messages in conversation."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
mock_llm = Mock()
mock_llm.complete.return_value = {"role": "assistant", "content": "Response"}
@@ -301,8 +301,8 @@ class TestChatCompletionsEdgeCases:
def test_extra_fields_in_request(self, memory):
"""Should ignore extra fields in request."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
mock_llm = Mock()
mock_llm.complete.return_value = {"role": "assistant", "content": "Response"}
@@ -325,8 +325,8 @@ class TestChatCompletionsEdgeCases:
def test_streaming_with_tool_call(self, memory, real_folder):
"""Should handle streaming with tool execution."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
from alfred.infrastructure.persistence import get_memory
mem = get_memory()
@@ -371,8 +371,8 @@ class TestChatCompletionsEdgeCases:
def test_concurrent_requests_simulation(self, memory):
"""Should handle rapid sequential requests."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
mock_llm = Mock()
mock_llm.complete.return_value = {"role": "assistant", "content": "Response"}
@@ -392,8 +392,8 @@ class TestChatCompletionsEdgeCases:
def test_llm_returns_json_in_response(self, memory):
"""Should handle LLM returning JSON in text response."""
from alfred.app import app
from alfred.agent import agent
from alfred.app import app
mock_llm = Mock()
mock_llm.complete.return_value = {

View File

@@ -1,4 +1,5 @@
"""Critical tests for configuration validation."""
import pytest
from alfred.agent.config import ConfigurationError, Settings

View File

@@ -1,9 +1,10 @@
"""Edge case tests for configuration and parameters."""
import os
import pytest
from unittest.mock import patch
import pytest
from alfred.agent.config import ConfigurationError, Settings
from alfred.agent.parameters import (
REQUIRED_PARAMETERS,

View File

@@ -1,7 +1,9 @@
"""Edge case tests for domain entities and value objects."""
import pytest
from datetime import datetime
import pytest
from alfred.domain.movies.entities import Movie
from alfred.domain.movies.value_objects import MovieTitle, Quality, ReleaseYear
from alfred.domain.shared.exceptions import ValidationError

View File

@@ -1,7 +1,9 @@
"""Tests for the Memory system."""
import pytest
from datetime import datetime
import pytest
from alfred.infrastructure.persistence import (
EpisodicMemory,
LongTermMemory,

View File

@@ -4,6 +4,7 @@ import json
import os
import pytest
from alfred.infrastructure.persistence import (
EpisodicMemory,
LongTermMemory,

View File

@@ -3,6 +3,7 @@
import inspect
import pytest
from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools

View File

@@ -1,6 +1,7 @@
"""Edge case tests for tool registry."""
import pytest
from alfred.agent.registry import Tool, make_tools

View File

@@ -1,7 +1,9 @@
"""Edge case tests for tools."""
import pytest
from unittest.mock import Mock, patch
import pytest
from alfred.agent.tools import api as api_tools
from alfred.agent.tools import filesystem as fs_tools
from alfred.infrastructure.persistence import get_memory

View File

@@ -1,7 +1,9 @@
"""Tests for filesystem tools."""
import pytest
from pathlib import Path
import pytest
from alfred.agent.tools import filesystem as fs_tools
from alfred.infrastructure.persistence import get_memory