Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ OLLAMA_ENDPOINT=http://localhost:11434
# Modèles par tier (adapté 16 GB RAM CPU-only, voir docs/LOCAL-LLM.md)
OLLAMA_MODEL_PREMIUM=qwen2.5-coder:7b-instruct-q4_K_M
OLLAMA_MODEL_STANDARD=qwen2.5-coder:7b-instruct-q4_K_M
OLLAMA_MODEL_FAST=qwen2.5-coder:3b-instruct-q4_K_M
# FAST pointe sur le même 7B : le 3B échoue de façon reproductible sur les
# schémas nested (voir tests/integration/test_services_llm_live.py).
OLLAMA_MODEL_FAST=qwen2.5-coder:7b-instruct-q4_K_M

# gRPC
GRPC_PORT=50051
Expand Down
96 changes: 93 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ jobs:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v3
uses: astral-sh/setup-uv@v8.3.2
with:
version: 'latest'
version: '0.11.30'
enable-cache: true

- name: Set up Python
Expand Down Expand Up @@ -60,5 +60,95 @@ jobs:

- name: Pytest (unit)
# Strict : un test qui échoue casse la CI.
# Skip integration/ car nécessite Redis + réseau HF Hub.
# Skip integration/ — voir le job dédié `integration-tests`.
run: uv run pytest tests/ --ignore=tests/integration -v

integration-tests:
name: Integration (Redis + MinIO)
runs-on: ubuntu-latest
# Ne tourne qu'après les unit tests — pas de gaspillage si le fondement casse.
needs: check
timeout-minutes: 15

services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 5

# Note : MinIO n'est PAS dans `services:` car GitHub Actions n'accepte
# pas de `command:` sur services et l'image `minio/minio` officielle
# requiert un arg (`server /data`). On la démarre manuellement en step
# ci-dessous — plus verbose mais fiable et sans dépendre d'une image
# tierce (Bitnami a supprimé `bitnami/minio:latest`).

steps:
- uses: actions/checkout@v7

- name: Start MinIO
run: |
docker run -d --name minio \
-p 9000:9000 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data
# Attente santé — /minio/health/live renvoie 200 quand prêt.
for i in $(seq 1 30); do
if curl -sf http://localhost:9000/minio/health/live > /dev/null; then
echo "MinIO ready after ${i}s"
break
fi
sleep 1
done
curl -sf http://localhost:9000/minio/health/live || (docker logs minio && exit 1)

- name: Install uv
uses: astral-sh/setup-uv@v8.3.2
with:
version: '0.11.30'
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies (frozen)
run: uv sync --frozen --extra dev

- name: Generate protobuf stubs
run: |
uv run python -m grpc_tools.protoc \
-Iproto \
--python_out=src/grpc_server/generated \
--grpc_python_out=src/grpc_server/generated \
proto/skilluv_ai.proto
for stub in src/grpc_server/generated/skilluv_ai_pb2_grpc.py; do
uv run python -c "
import re, sys
p = sys.argv[1]
s = open(p, 'r', encoding='utf-8').read()
s = re.sub(r'^import (\w+_pb2) as ', r'from . import \1 as ', s, flags=re.MULTILINE)
open(p, 'w', encoding='utf-8').write(s)
" "$stub"
done

- name: Pytest (integration Redis/MinIO)
# Exclusions :
# - test_grpc_full_chain.py : pull le modèle sentence-transformers
# (~1 GB HF Hub) et pré-warme les embeddings de plagiat. Trop
# lourd pour la CI, à lancer en local.
# - test_ollama_live.py + test_services_llm_live.py : nécessitent
# un serveur Ollama local — pas viable en CI GitHub Actions.
env:
REDIS_URL: redis://localhost:6379/0
MINIO_ENDPOINT: localhost:9000
run: |
uv run pytest tests/integration/ \
--ignore=tests/integration/test_grpc_full_chain.py \
--ignore=tests/integration/test_ollama_live.py \
--ignore=tests/integration/test_services_llm_live.py \
-v
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ jobs:
# Le runner default (~14 GB) ne suffit pas avec la layer cache Docker.
# On libère ~30 GB avant de commencer.
- name: Free disk space on runner
uses: jlumbroso/free-disk-space@main
# Pinned to SHA (main = supply-chain risk). Bump when auditing new commits.
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # 2023-10-18
with:
tool-cache: true
android: true
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*

# uv (astral) — pin explicite pour reproductibilité
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.30 /uv /usr/local/bin/uv

WORKDIR /app

Expand Down
10 changes: 5 additions & 5 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

services:
redis:
image: redis:7-alpine
image: redis:8.8-alpine
restart: unless-stopped
volumes:
- redis_data:/data
Expand All @@ -34,7 +34,7 @@ services:
limits: { cpus: "0.5", memory: 256M }

minio:
image: minio/minio:latest
image: minio/minio:RELEASE.2025-10-15T17-29-55Z
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER manquant}
Expand Down Expand Up @@ -77,7 +77,7 @@ services:
limits: { cpus: "2.0", memory: 2G }

prometheus:
image: prom/prometheus:latest
image: prom/prometheus:v3.13.1
restart: unless-stopped
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
Expand All @@ -93,7 +93,7 @@ services:
alertmanager:
# Décommentez le mount après avoir créé alertmanager.local.yml sur l'hôte
# (copie de infra/alertmanager/alertmanager.yml, remplir CHANGE_ME).
image: prom/alertmanager:latest
image: prom/alertmanager:v0.33.1
restart: unless-stopped
volumes:
- ./infra/alertmanager/alertmanager.local.yml:/etc/alertmanager/alertmanager.yml:ro
Expand All @@ -105,7 +105,7 @@ services:
profiles: ["alerting"] # Activé via --profile alerting

grafana:
image: grafana/grafana:latest
image: grafana/grafana:13.0.4
restart: unless-stopped
ports:
# Grafana derrière Caddy TLS ; on n'expose pas en clair sur 0.0.0.0.
Expand Down
8 changes: 4 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:
# === Infrastructure ===

redis:
image: redis:7-alpine
image: redis:8.8-alpine
ports:
- "6379:6379"
volumes:
Expand All @@ -14,7 +14,7 @@ services:
retries: 5

minio:
image: minio/minio:latest
image: minio/minio:RELEASE.2025-10-15T17-29-55Z
ports:
- "9000:9000"
- "9001:9001"
Expand All @@ -33,7 +33,7 @@ services:
# === Monitoring (profil: monitoring) ===

prometheus:
image: prom/prometheus:latest
image: prom/prometheus:v3.13.1
profiles: ["monitoring"]
ports:
- "9090:9090"
Expand All @@ -44,7 +44,7 @@ services:
- skilluv-ai

grafana:
image: grafana/grafana:latest
image: grafana/grafana:13.0.4
profiles: ["monitoring"]
ports:
- "3000:3000"
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-cov>=6.0",
# Rerun automatique des tests marqués `@pytest.mark.flaky` — utilisé pour
# les tests d'intégration LLM local qui sont probabilistes par nature.
"pytest-rerunfailures>=15.0",
"ruff>=0.8",
"mypy>=1.13",
"fakeredis>=2.26",
Expand All @@ -58,6 +61,15 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
# Tests d'intégration qui parlent à un vrai serveur Ollama local sur
# $OLLAMA_ENDPOINT (défaut http://localhost:11434). Skippés automatiquement
# si Ollama n'est pas joignable. Lents (10s-2min par test selon modèle).
# Run : `uv run pytest -m local_llm` (ou `RUN_LOCAL_LLM=1 uv run pytest`).
"local_llm: tests d'intégration contre un vrai serveur Ollama local",
# Tests d'intégration nécessitant Redis / MinIO (docker compose up -d).
"integration: tests d'intégration nécessitant des services externes",
]

[tool.ruff]
target-version = "py312"
Expand Down
15 changes: 14 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,22 @@ class Settings(BaseSettings):

# Mapping tier -> modèle Ollama. Défauts adaptés à ~16 GB RAM CPU-only.
# Voir docs/LOCAL-LLM.md pour d'autres profils (32 GB, GPU dédié, etc.).
#
# NB : les 3 tiers pointent volontairement sur le même modèle 7B. Le 3B a
# été testé et échoue de façon reproductible sur les schémas nested
# (typiquement `suggest_career_path` — voir tests/integration/
# test_services_llm_live.py). Un seul modèle à pull en prod, latence
# uniforme, pas de surprise. Pour ré-introduire un modèle plus petit sur
# le tier FAST, valider d'abord contre les vrais schémas des 3 services.
ollama_model_premium: str = "qwen2.5-coder:7b-instruct-q4_K_M"
ollama_model_standard: str = "qwen2.5-coder:7b-instruct-q4_K_M"
ollama_model_fast: str = "qwen2.5-coder:3b-instruct-q4_K_M"
ollama_model_fast: str = "qwen2.5-coder:7b-instruct-q4_K_M"

# Nombre max de retries si Ollama produit un JSON invalide / non conforme
# au schema. Ollama < Claude sur le structured output — un budget de 2
# retries (soit 3 tentatives) est le sweet spot mesuré avec Qwen 7B sur
# les schémas de challenge_generation (~10 champs).
ollama_max_retries: int = 2

model_config = {
"env_file": ".env",
Expand Down
59 changes: 51 additions & 8 deletions src/llm/ollama_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
logger = get_logger("llm.ollama")

_DEFAULT_ENDPOINT = "http://localhost:11434"
_MAX_RETRIES = 1
_DEFAULT_MAX_RETRIES = 2
_REQUEST_TIMEOUT_S = 300.0 # LLM local sur CPU peut être lent (voir docs/LOCAL-LLM.md)


Expand All @@ -51,10 +51,11 @@ def __init__(self) -> None:
settings, "ollama_model_standard", "qwen2.5-coder:7b-instruct-q4_K_M"
),
ModelTier.FAST: getattr(
settings, "ollama_model_fast", "qwen2.5-coder:3b-instruct-q4_K_M"
settings, "ollama_model_fast", "qwen2.5-coder:7b-instruct-q4_K_M"
),
}
self._client: httpx.AsyncClient | None = None
self._max_retries = getattr(settings, "ollama_max_retries", _DEFAULT_MAX_RETRIES)

def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
Expand Down Expand Up @@ -91,20 +92,32 @@ async def complete_structured(
)

last_error: str | None = None
for attempt in range(_MAX_RETRIES + 1):
last_raw: str | None = None
for attempt in range(self._max_retries + 1):
user_prompt = user
if attempt > 0 and last_error:
# On renvoie la réponse invalide + l'erreur au modèle.
# Sans la réponse précédente, les petits modèles (Qwen 3B)
# ont tendance à repartir de zéro et perdre la structure
# globale en essayant de corriger le champ précis.
prev_raw = (last_raw or "")[:2000]
user_prompt = (
f"{user}\n\n"
f"[Correction] Ta réponse précédente était invalide : {last_error}. "
"Réessaie en respectant strictement le schéma JSON demandé."
"---\n"
"Ta tentative précédente était :\n"
f"```json\n{prev_raw}\n```\n\n"
f"Elle est invalide : {last_error}.\n"
"Corrige UNIQUEMENT ce qui est signalé, garde le reste "
"de la structure. Réponds avec le JSON complet corrigé, "
"sans commentaire."
)
raw = await self._call_ollama_chat(
model=model,
system=system_with_schema,
user=user_prompt,
max_tokens=max_tokens,
)
last_raw = raw
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
Expand Down Expand Up @@ -135,7 +148,7 @@ async def complete_structured(
return data

raise ValidationError(
f"Ollama {model} did not produce valid JSON after {_MAX_RETRIES + 1} attempts",
f"Ollama {model} did not produce valid JSON after {self._max_retries + 1} attempts",
{"last_error": last_error},
)

Expand All @@ -161,23 +174,53 @@ async def _call_ollama_chat(
}
try:
response = await client.post("/api/chat", json=payload)
response.raise_for_status()
except httpx.HTTPError as e:
external_errors_total.labels(service="ollama").inc()
raise ExternalServiceError(
f"Ollama HTTP error ({model}): {e}",
{"endpoint": self._endpoint},
) from e
# 404 = modèle non pull. Message d'aide explicite pour le dev.
if response.status_code == 404:
external_errors_total.labels(service="ollama").inc()
raise ExternalServiceError(
f"Ollama model {model!r} not found — run `ollama pull {model}` first",
{"endpoint": self._endpoint, "model": model},
)
try:
response.raise_for_status()
except httpx.HTTPError as e:
external_errors_total.labels(service="ollama").inc()
raise ExternalServiceError(
f"Ollama HTTP error ({model}): {e}",
{"endpoint": self._endpoint, "status": response.status_code},
) from e
data = response.json()
# Format /api/chat : {"message": {"role": "assistant", "content": "..."}, ...}
message = data.get("message", {})
return message.get("content", "")
content = message.get("content", "")
# Content vide = symptôme (OOM, modèle mal chargé, num_predict=0).
# On préfère une erreur explicite plutôt que de laisser retomber
# sur JSONDecodeError → retry inutile.
if not content or not content.strip():
external_errors_total.labels(service="ollama").inc()
raise ExternalServiceError(
f"Ollama {model} returned empty content — check model status "
f"(OOM? not loaded?) via `ollama ps`",
{"endpoint": self._endpoint, "model": model},
)
return content


def _build_schema_hint(schema: dict[str, Any]) -> str:
"""Rend le JSON Schema sous forme lisible pour le prompt.

Le modèle est plus fidèle si le schema est dans le prompt textuel plutôt
qu'attendu implicitement.

NB: on avait testé un enrichissement (règles + liste des `required`) qui
empirait les petits modèles (Qwen 3B) — ils décrochaient sur la
structure racine à cause de la surcharge d'instructions. Version minimale
conservée.
"""
return "```json\n" + json.dumps(schema, indent=2, ensure_ascii=False) + "\n```"
Loading
Loading