diff --git a/.env.example b/.env.example index a79422d..cfb4d1d 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,24 @@ BOOKSTACK_TOKEN_ID=votre_token_bookstack BOOKSTACK_TOKEN_SECRET=votre_secret_token BOOKSTACK_URL=http://localhost:6875 -MODEL_LLM=mon-model-IA -MODEL_EMBEDDING=mon-model-embedding -OPENAI_API_KEY=myapikey \ No newline at end of file +OPENAI_API_KEY=sk-proj-your-api-key +MODEL_LLM=myllmmodel +MODEL_EMBEDDING=myembeddingmodel + +COLLECTION_NAME=collection_test +VECTOR_STORE_DIR=vector-store +TOP_K=4 +BACKEND_MODEL_ID=chatbot-rag +SYSTEM_PROMPT=You are a helpful assistant for internal documentation. + +ANYLLM_ADMIN_PASSWORD=your_anythingllm_password +JWT_SECRET=your-random-secret-key + +# Network binding / ports +CHATBOT_API_BIND_IP=1.1.1.1 +CHATBOT_API_HOST_PORT=CHATPORT +CHATBOT_API_INTERNAL_PORT=CHATPORT + +ANYTHINGLLM_BIND_IP=1.1.1.1 +ANYTHINGLLM_HOST_PORT=PORT +ANYTHINGLLM_CONTAINER_PORT=PORT diff --git a/.github/workflows/ghcr.yaml b/.github/workflows/ghcr.yaml new file mode 100644 index 0000000..6abb288 --- /dev/null +++ b/.github/workflows/ghcr.yaml @@ -0,0 +1,31 @@ +name: Create and publish all Docker images + +on: + push: + branches: ["main","chore/github-registry"] + +jobs: + build-docker-image: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and publish Chabot Docker Image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.api + push: true + tags: | + ghcr.io/xpeho/chatbot-api:latest + ghcr.io/xpeho/chatbot-api:${{ github.sha }} diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..9b42bc2 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY app /app/app +COPY vector-store /app/vector-store + +EXPOSE 8000 + +CMD ["sh", "-lc", "uvicorn app.main:app --host 0.0.0.0 --port ${CHATBOT_API_INTERNAL_PORT}"] diff --git a/README.md b/README.md index 85712bd..e9c5b81 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,108 @@ # Chatbot -## Export BookStack pages to PDF +This project provides: +- BookStack page export to PDF +- Vector store indexing (Chroma) +- A RAG chatbot API (OpenAI-compatible endpoints) +- An Open WebUI interface connected to the RAG API -### Prerequisites -- Python 3.9+ -- BookStack API token (token id + token secret) -- BookStack URL +## Prerequisites + +- Python 3.11+ +- Docker + Docker Compose +- OpenAI API key with active billing/quota +- BookStack API token (for export only) + +## Environment setup + +Create `.env` from `.env.example` and fill values: + +```env +OPENAI_API_KEY=sk-proj-your-api-key +MODEL_LLM=yout_model +MODEL_EMBEDDING=your_embedding_model + +BOOKSTACK_URL=your_url +BOOKSTACK_TOKEN_ID=your_bookstack_token_id +BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret + +CHATBOT_API_BIND_IP=IP +CHATBOT_API_HOST_PORT=PORT +CHATBOT_API_INTERNAL_PORT=PORT +ANYTHINGLLM_BIND_IP=IP +ANYTHINGLLM_HOST_PORT=PORT +``` + +## Python setup -### Setup -1. Create a virtual environment ```bash python -m venv .venv source .venv/bin/activate +pip install -r requirements.txt ``` -2. Install dependencies +## 1) Export BookStack pages as PDFs + ```bash -pip install -r requirements.txt +python export_pages.py ``` -3. Create a `.env` file (or update it) at the project root: -```env -BOOKSTACK_URL=your_bookstack_url -BOOKSTACK_TOKEN_ID=your_token_id -BOOKSTACK_TOKEN_SECRET=your_token_secret -``` +PDF files are written to `exports/`. + +## 2) Build / refresh vector store -## To run a notebook ```bash -pip install jupyter ipykernel -python -m ipykernel install --user --name chatbot-venv --display-name "Python (chatbot-venv)" -jupyter notebook +python reload_vector_store.py ``` -### Run the export +This indexes documents from `exports/` into `vector-store/`. + +## 3) Run chatbot API + Open WebUI + ```bash -python export_pages.py +docker compose up --build ``` -PDFs will be saved to `exports/` +Access: +- AnythingLLM: `http://localhost:${ANYTHINGLLM_HOST_PORT}` (default: `3001`) +- Chatbot API health: `http://localhost:${CHATBOT_API_HOST_PORT}/health` (default: `8000`) + +Open WebUI is configured to call the local chatbot backend through OpenAI-compatible routes: +- `GET /v1/models` +- `POST /v1/chat/completions` + +## 4) Expose AnythingLLM on internet (secure minimal setup) + +Files: +- `docker-compose.anythingllm.secure.yml` +- `deploy/Caddyfile` + +This stack puts Caddy in front of AnythingLLM: +- HTTPS with automatic TLS certificates +- HTTP Basic Auth at proxy level +- AnythingLLM not exposed directly (internal only) + +Steps: + +1. Create env file: + +docker compose up -# Pricing OPenAI -Pour un rechargement de base on utilise en moyenne 67 000 tokens soit 1/7 centimes. -Une question côut 1/40 centimes (une toute simple). +Security notes: +- Keep AnythingLLM built-in auth enabled (multi-user recommended for internet exposure). +- Disable public signup inside AnythingLLM unless explicitly needed. +- Keep `OPENAI_API_KEY` only inside server env, never in frontend code. + +## 5) Automatize the vector store reload : +Create a job to automatically update the vectore store weekly : +CRON_TZ=Europe/Paris +0 7 * * 1 chatbot/reload_job.sh >> chatbot/reindex.log 2>&1 + + +## Optional: notebooks + +```bash +pip install jupyter ipykernel +python -m ipykernel install --user --name chatbot-venv --display-name "Python (chatbot-venv)" +jupyter notebook +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..32a1581 --- /dev/null +++ b/app/main.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +import json +import os +import time +import uuid +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +BACKEND_MODEL_ID = os.getenv("BACKEND_MODEL_ID", "chatbot-rag") + + +class ChatMessage(BaseModel): + role: str + content: Any + + +class ChatCompletionRequest(BaseModel): + model: str | None = None + messages: list[ChatMessage] + temperature: float | None = None + stream: bool | None = False + max_tokens: int | None = None + + +app = FastAPI(title="Chatbot API", version="0.1.0") + + +@app.get("/v1/models") +def list_models() -> dict[str, Any]: + return { + "object": "list", + "data": [ + { + "id": BACKEND_MODEL_ID, + "object": "model", + "owned_by": "chatbot", + } + ], + } + + +@app.post("/v1/chat/completions") +def chat_completions(request: ChatCompletionRequest): + if not request.messages: + raise HTTPException(status_code=400, detail="messages is required") + + answer = f"Reponse bidon API." + created = int(time.time()) + completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" + model_id = request.model or BACKEND_MODEL_ID + + if request.stream: + ##réponse compatible ANYTHINGLLM + def event_stream(): + chunk_1 = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_id, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + } + yield f"data: {json.dumps(chunk_1)}\n\n" + + chunk_2 = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_id, + "choices": [{"index": 0, "delta": {"content": answer}, "finish_reason": None}], + } + yield f"data: {json.dumps(chunk_2)}\n\n" + + chunk_3 = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_id, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + yield f"data: {json.dumps(chunk_3)}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + return { + "id": completion_id, + "object": "chat.completion", + "created": created, + "model": model_id, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": answer}, + "finish_reason": "stop", + } + ], + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d1c8e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + chatbot-api: + build: + context: . + dockerfile: Dockerfile.api + container_name: chatbot-api-local + env_file: + - .env + environment: + - VECTOR_STORE_DIR=/app/vector-store + - COLLECTION_NAME=collection_test + - TOP_K=4 + - BACKEND_MODEL_ID=chatbot-rag + volumes: + - ./vector-store:/app/vector-store + ports: + - "${CHATBOT_API_BIND_IP}:${CHATBOT_API_HOST_PORT}:${CHATBOT_API_INTERNAL_PORT}" + restart: unless-stopped + + anythingllm: + image: mintplexlabs/anythingllm:latest + container_name: anythingllm-local + depends_on: + - chatbot-api + cap_add: + - SYS_ADMIN + env_file: + - .env + environment: + - STORAGE_DIR=/app/server/storage + - GENERIC_OPEN_AI_BASE_PATH=http://chatbot-api:${CHATBOT_API_INTERNAL_PORT}/v1 + - OPEN_AI_BASE_PATH=http://chatbot-api:${CHATBOT_API_INTERNAL_PORT}/v1 + - AUTH_TOKEN=${ANYLLM_ADMIN_PASSWORD} + - JWT_SECRET=${JWT_SECRET} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - anythingllm-local-storage:/app/server/storage + ports: + - "${ANYTHINGLLM_BIND_IP}:${ANYTHINGLLM_HOST_PORT}:${ANYTHINGLLM_HOST_PORT}" + restart: unless-stopped + +volumes: + anythingllm-local-storage: diff --git a/docker/docker-compose-prod.yml b/docker/docker-compose-prod.yml new file mode 100644 index 0000000..9fe483a --- /dev/null +++ b/docker/docker-compose-prod.yml @@ -0,0 +1,40 @@ +services: + chatbot-api: + image: ghcr.io/xpeho/chatbot-api:latest + pull_policy: always + env_file: + - .env + environment: + - VECTOR_STORE_DIR=/app/vector-store + - COLLECTION_NAME=collection_test + - TOP_K=4 + - BACKEND_MODEL_ID=chatbot-rag + volumes: + - ./vector-store:/app/vector-store + ports: + - "${CHATBOT_API_BIND_IP}:${CHATBOT_API_HOST_PORT}:${CHATBOT_API_INTERNAL_PORT}" + restart: unless-stopped + + anythingllm: + image: mintplexlabs/anythingllm:latest + container_name: anythingllm-local + depends_on: + - chatbot-api + cap_add: + - SYS_ADMIN + env_file: + - .env + environment: + - STORAGE_DIR=/app/server/storage + - GENERIC_OPEN_AI_BASE_PATH=http://chatbot-api:${CHATBOT_API_INTERNAL_PORT}/v1 + - OPEN_AI_BASE_PATH=http://chatbot-api:${CHATBOT_API_INTERNAL_PORT}/v1 + - AUTH_TOKEN=${ANYLLM_ADMIN_PASSWORD} + - JWT_SECRET=${JWT_SECRET} + volumes: + - anythingllm-local-storage:/app/server/storage + ports: + - "${ANYTHINGLLM_BIND_IP}:${ANYTHINGLLM_HOST_PORT}:${ANYTHINGLLM_HOST_PORT}" + restart: unless-stopped + +volumes: + anythingllm-local-storage: diff --git a/reload_job.sh b/reload_job.sh new file mode 100755 index 0000000..552aac3 --- /dev/null +++ b/reload_job.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml" +LOCK_DIR="/tmp/chatbot-reindex-lock" + +# Prevent overlapping runs. +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "[reindex] another run is already in progress" + exit 0 +fi +trap 'rmdir "$LOCK_DIR"' EXIT + +cd "$PROJECT_DIR" + +echo "[reindex] starting export + vector store reload" +docker compose -f "$COMPOSE_FILE" run --rm --no-deps --build \ + -v "$PROJECT_DIR:/work" \ + -w /work \ + chatbot-api \ + sh -lc "python export_pages.py && python reload_vector_store.py" +echo "[reindex] done" diff --git a/requirements.txt b/requirements.txt index 9c37cbc..3417529 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ python-dotenv>=1.0.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 langchain-openai langchain_chroma langchain-text-splitters @@ -6,4 +8,4 @@ langchain-community langgraph pathlib tqdm -pypdf \ No newline at end of file +pypdf