fin étape 2 (dev_agent) + automatisation lancement projet/arrêt projet + séparation des docker-compose

This commit is contained in:
Chevallier
2026-06-22 15:55:59 +02:00
parent 981cfb6f2e
commit 17e887b268
18 changed files with 633 additions and 212 deletions

8
.gitignore vendored
View File

@@ -45,4 +45,10 @@ dist/
*.egg-info/
# Jupyter
.ipynb_checkpoints/
.ipynb_checkpoints/
# Token files
.gitea_token
# Repo temp
temp_repos/

140
README.md
View File

@@ -67,70 +67,92 @@ Tâches :
- Documentation finale
- Présentation
## Lancer le projet
```bash
chmod +x run_project.sh
./run_project.sh
```
## Structure du projet
```bash
backend/
├── app/
│ ├── api/
│ │ ├── routes/
│ │ │ ├── health.py
ARC/
├── backend/
├── app/
│ ├── agents/ # Gestion agents IA
│ │ │ ├── pm_agent.py
│ │ │ ├── dev_agent.py
│ │ │ └── qa_agent.py
│ │ │
│ │ ├── api/
│ │ │ ├── routes/
│ │ │ │ ├── health.py
│ │ │ │ └── workflow.py
│ │ │ └── deps.py
│ │ │
│ │ ├── core/
│ │ │ ├── config.py
│ │ │ ├── logging.py
│ │ │ └── security.py
│ │ │
│ │ ├── graph/ # LangGraph
│ │ │ ├── state.py
│ │ │ ├── nodes.py
│ │ │ └── workflow.py
│ │ └── deps.py
│ │
│ │ ├── llm/ # appels modèles
│ │ │ ├── client.py # wrapper dappel
│ │ │ ├── prompts.py # prompts centralisés
│ │ │ └── providers.py # Gemma/llama.cpp....
│ │ │
│ │ ├── models/ # modèles métier / persistance (métadonnées dun projet/version/statut/lien Git/hash/tags)
│ │ │ └── project.py
│ │ │
│ │ ├── repositories/ # accès externes, Qdrant / Redis / stockage
│ │ │ ├── qdrant_repository.py
│ │ │ ├── redis_repository.py
│ │ │ └── project_repository.py
│ │ │
│ │ ├── sandbox/
│ │ │ └── docker_runner.py
│ │ │
│ │ ├── schemas/ # Pydantic
│ │ │ ├── api.py
│ │ │ ├── spec.py
│ │ │ ├── code_output.py
│ │ │ └── project.py
│ │ │
│ │ ├── services/
│ │ │ ├── workflow_service.py
│ │ │ ├── embedding_service.py
│ │ │ ├── retrieval_service.py
│ │ │ └── delivery_service.py
│ │ │
│ │ └─── main.py
│ │
│ ├── core/
│ │ ├── config.py
│ │ ── logging.py
│ │ └── security.py
│ ├── public/
│ │ ├── logo_dark.png
│ │ ── logo_light.png
│ │
│ ├── graph/ # LangGraph
│ │ ├── state.py
│ │ ├── nodes.py
│ │ ── workflow.py
│ ├── tests/
│ │ ├── test_health.py
│ │ ├── test_workflow.py
│ │ ── test_agents.py
│ │ ├── test_gemma.py
│ │ ├── test_mistral.py
│ │ ├── test_qdrant.py
│ │ └── test_snowflake.py
│ │
│ ├── agents/ # Gestion agents IA
│ ├── pm_agent.py
│ ├── dev_agent.py
│ └── qa_agent.py
│ ├── schemas/ # Pydantic
│ ├── api.py
│ ├── spec.py
│ └── project.py
│ ├── models/ # modèles métier / persistance (métadonnées dun projet/version/statut/lien Git/hash/tags)
│ │ └── project.py
│ │
│ ├── services/
│ │ ├── workflow_service.py
│ │ ├── embedding_service.py
│ │ ├── retrieval_service.py
│ │ └── delivery_service.py
│ │
│ ├── repositories/ # accès externes, Qdrant / Redis / stockage
│ │ ├── qdrant_repository.py
│ │ ├── redis_repository.py
│ │ └── project_repository.py
│ │
│ ├── llm/ # appels modèles
│ │ ├── client.py # wrapper dappel
│ │ ├── prompts.py # prompts centralisés
│ │ └── providers.py # Gemma/llama.cpp....
│ │
│ ├── sandbox/
│ │ └── docker_runner.py
│ │
│ ├── main.py
│ └── __init__.py
│ ├── .env
│ ├── chainlit_app.py
│ ├── chainlit_fr_FR.md
├── docker-compose-ai.yml # Conteneurs pour les agents et l'embedding
├── docker-compose-infra.yml # Conteneurs pour les BDD
│ ├── docker-compose.yml # Conteneur de l'application
│ ├── Dockerfile
│ ├── README.md
├── requirements.txt
└── start.sh # Lance les applications
── chainlit_app.py
├── tests/
│ ├── test_health.py
│ ├── test_workflow.py
│ └── test_agents.py
├── .env
├── requirements.txt
├── Dockerfile
└── README.md
── orchestrator.py # Lance le projet
```

View File

@@ -12,6 +12,7 @@ RUN pip install --no-cache-dir \
--trusted-host pypi.python.org \
--trusted-host files.pythonhosted.org \
-r requirements.txt
RUN apt-get update && apt-get install -y git
COPY . .

View File

@@ -25,7 +25,20 @@ chainlit run chainlit_app.py --port 8001
## Lancement auto
```bash
docker compose up --build
python orchestrator.py
```
## Arrêt propre
```bash
python stop_project.py
```
# Logs
```bash
docker compose -p arc-app logs -f
docker compose -p arc-ai logs -f
```
## Tests

View File

@@ -1,6 +1,9 @@
import json
import logging
import httpx
import os
import shutil
import subprocess
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
from app.core.config import settings
@@ -44,14 +47,142 @@ async def run_dev_agent(spec: dict, qa_feedback: list = None) -> dict:
messages.append(HumanMessage(content=user_content))
try:
validated_code = await structured_llm.ainvoke(messages)
logger.info(f"[Dev Agent] ✅ Code généré et validé avec succès ({len(validated_code.files)} fichiers)")
return validated_code.model_dump()
code_data = await structured_llm.ainvoke(messages)
logger.info(f"[Dev Agent] ✅ Code généré ({len(code_data.files)} fichiers)")
return await _deploy_to_gitea(code_data, spec.get("title", "project"))
except Exception as e:
logger.error(f"[Dev Agent] ❌ Échec de la génération/validation : {type(e).__name__}: {str(e)}")
return _generate_fallback_code_output(spec)
async def _ensure_gitea_repo(base_name: str) -> str:
"""
Vérifie si le repo existe. Si oui, incrémente un suffixe (_1, _2...)
jusqu'à trouver un nom libre, puis le crée et retourne ce nom unique.
"""
async with httpx.AsyncClient(verify=False) as client:
headers = {"Authorization": f"token {settings.gitea_token}"}
repo_name = base_name
counter = 1
# 1. Boucle de recherche d'un nom disponible via l'endpoint direct du repo
while True:
check_url = f"{settings.gitea_api_url}/repos/{settings.gitea_admin_user}/{repo_name}"
response = await client.get(check_url, headers=headers)
if response.status_code == 404:
# Parfait ! Le 404 signifie que ce nom n'existe pas encore, il est libre.
break
elif response.status_code == 200:
# Le nom est déjà pris, on tente avec le compteur suivant
repo_name = f"{base_name}_{counter}"
counter += 1
else:
# Sécurité si l'API renvoie autre chose (ex: 401 Unauthorized)
raise Exception(f"Erreur Gitea lors de la vérification ({response.status_code}) : {response.text}")
# 2. Si on arrive ici, repo_name est garanti unique et disponible. On le crée.
logger.info(f"[Gitea] Création du nouveau dépôt unique '{repo_name}'...")
create_url = f"{settings.gitea_api_url}/user/repos"
payload = {
"name": repo_name,
"auto_init": True,
"description": "Generated by AI Dev Agent (Unique Instance)"
}
create_res = await client.post(create_url, headers=headers, json=payload)
if create_res.status_code in [201, 200]:
logger.info(f"[Gitea] Dépôt '{repo_name}' créé avec succès.")
return repo_name
else:
logger.error(f"[Gitea] Erreur lors de la création : {create_res.text}")
raise Exception(f"Impossible de créer le dépôt '{repo_name}' sur Gitea.")
async def _deploy_to_gitea(code_data: ProjectCodeOutput, project_title: str) -> dict:
"""Gère le cycle de vie Git : Clone/Init -> Write -> Commit -> Push."""
base_name = "".join(c for c in project_title.lower().replace(" ", "_") if c.isalnum() or c == "_")
# --- MODIFICATION ICI : On récupère le nom unique validé et créé par Gitea ---
try:
repo_name = await _ensure_gitea_repo(base_name)
except Exception as e:
return {
"status": "partial_success_git_failed",
"error": str(e),
"spec_title": code_data.spec_title,
"tree": code_data.tree
}
work_dir = os.path.abspath(f"./temp_repos/{repo_name}")
if os.path.exists(work_dir):
shutil.rmtree(work_dir)
os.makedirs(work_dir)
gitea_host = settings.gitea_base_url.replace("http://", "").replace("https://", "")
remote_url = f"http://{settings.gitea_admin_user}:{settings.gitea_token}@{gitea_host}/{settings.gitea_admin_user}/{repo_name}.git"
try:
# 2. Vérifier si on doit Cloner ou faire un Init (Gitea ayant auto_init=True, il va cloner)
check_remote = subprocess.run(["git", "ls-remote", remote_url], cwd=work_dir, capture_output=True)
if check_remote.returncode == 0:
logger.info(f"[Git] Dépôt distant initialisé. Clonage de la structure de base...")
subprocess.run(["git", "clone", remote_url, "."], cwd=work_dir, check=True, capture_output=True)
is_update = True
else:
logger.info(f"[Git] Initialisation locale...")
subprocess.run(["git", "init"], cwd=work_dir, check=True, capture_output=True)
subprocess.run(["git", "remote", "add", "origin", remote_url], cwd=work_dir, check=True, capture_output=True)
is_update = False
subprocess.run(["git", "config", "user.name", "ARC Dev Agent"], cwd=work_dir, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "dev_agent@arc.local"], cwd=work_dir, check=True, capture_output=True)
# 3. Écriture des fichiers générés par le LLM
for file_info in code_data.files:
file_path = os.path.join(work_dir, file_info.path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(file_info.content)
# 4. Commit et Push
subprocess.run(["git", "add", "."], cwd=work_dir, check=True, capture_output=True)
status = subprocess.run(["git", "status", "--porcelain"], cwd=work_dir, capture_output=True, text=True)
if status.stdout.strip():
subprocess.run(["git", "commit", "-m", "Update from Dev Agent (AI Generation)"], cwd=work_dir, check=True, capture_output=True)
if is_update:
subprocess.run(["git", "pull", "origin", "main", "--rebase"], cwd=work_dir, check=True, capture_output=True)
subprocess.run(["git", "push", "origin", "main"], cwd=work_dir, check=True, capture_output=True)
logger.info(f"[Git] ✅ Push réussi sur {repo_name}")
else:
logger.info("[Git] Aucun changement détecté, skip commit/push.")
return {
"status": "success",
"repo_url": f"{settings.gitea_base_url}/{settings.gitea_admin_user}/{repo_name}",
"files_count": len(code_data.files),
"spec_title": code_data.spec_title,
"tree": code_data.tree
}
except Exception as e:
logger.error(f"[Git Deployment Error] {str(e)}")
return {
"status": "partial_success_git_failed",
"error": str(e),
"spec_title": code_data.spec_title,
"tree": code_data.tree
}
finally:
if os.path.exists(work_dir):
shutil.rmtree(work_dir)
def _generate_fallback_code_output(spec: dict) -> dict:
"""Génère un livrable minimal de secours en cas de crash du LLM."""
logger.warning("[Dev Agent] Génération du package de secours (Fallback)")

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter
from fastapi import APIRouter, Request
from app.schemas.api import WorkflowRequest, WorkflowResponse
from app.services.workflow_service import run_arc_workflow
@@ -6,7 +6,9 @@ router = APIRouter(tags=["workflow"])
@router.post("/workflow/run", response_model=WorkflowResponse)
async def run_workflow(payload: WorkflowRequest):
result = await run_arc_workflow(payload.model_dump())
async def run_workflow(payload: WorkflowRequest, request: Request):
qdrant_repo = request.app.state.qdrant_repo
result = await run_arc_workflow(payload.model_dump(), qdrant_repo)
return WorkflowResponse(**result)

View File

@@ -1,3 +1,5 @@
import http
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
@@ -18,11 +20,19 @@ class Settings(BaseSettings):
embedding_base_url: str = "http://localhost:8002/v1"
embedding_model: str = "snowflake-arctic-embed-m-v1.5"
gitea_base_url: str
gitea_api_url: str
gitea_admin_user: str
gitea_admin_password: str
gitea_token: Optional[str] = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)
settings = Settings()

View File

@@ -4,6 +4,7 @@ from app.agents.dev_agent import run_dev_agent
from app.agents.qa_agent import run_qa_agent
from app.services.retrieval_service import find_existing_project
from app.graph.state import WorkflowState
from langchain_core.runnables import RunnableConfig
async def pm_node(state: WorkflowState):
history = state.get("chat_history", []) or []
@@ -32,8 +33,13 @@ async def pm_node(state: WorkflowState):
"loop_count": 0,
}
async def retrieval_node(state: WorkflowState):
existing_project = await find_existing_project(state["user_input"])
async def retrieval_node(state: WorkflowState, config: RunnableConfig):
qdrant_repo = config.get("configurable", {}).get("qdrant_repo")
if not qdrant_repo:
raise ValueError("❌ Erreur : Le repository Qdrant n'a pas été transmis au graphe.")
existing_project = await find_existing_project(qdrant_repo, state["user_input"])
return {
"existing_project": existing_project,
"status": "existing_found" if existing_project else "no_existing_project",
@@ -69,45 +75,4 @@ async def human_review_node(state: WorkflowState):
"existing_project_approved": True,
"is_completed": True,
"status": "approved_by_human"
}
# def inspect_specifications_gaps(spec_dict: dict) -> list[str]:
# missing_fields = []
# # 1. Vérifications globales (clés alignées sur ProjectSpec)
# if not spec_dict.get("title"): missing_fields.append("title")
# if not spec_dict.get("description"): missing_fields.append("description")
# if not spec_dict.get("requirements"): missing_fields.append("requirements")
# # 2. Entrées/Sorties
# io = spec_dict.get("io_config", {})
# if io.get("has_inputs") is True and not io.get("input_type"):
# missing_fields.append("io_config.input_type")
# if io.get("has_outputs") is True and not io.get("output_type"):
# missing_fields.append("io_config.output_type")
# # 3. Authentification
# auth = spec_dict.get("auth_config", {})
# if auth.get("requires_auth") is True and not auth.get("auth_method"):
# missing_fields.append("auth_config.auth_method")
# return missing_fields
# def generate_clarifying_questions_prompt(missing_fields: list[str]) -> str:
# # Le mapping utilise désormais les EXACTES mêmes clés
# mapping_instructions = {
# "title": "- Préciser un titre pour le projet.",
# "description": "- Expliquer le but global du script.",
# "requirements": "- Lister les fonctionnalités attendues étape par étape.",
# "io_config.input_type": "- Préciser le type et le format des fichiers/données d'entrée (CSV, dossier, etc.).",
# "io_config.output_type": "- Préciser le type et le format attendus en sortie (Excel, PDF, log, etc.).",
# "auth_config.auth_method": "- Clarifier la méthode d'accès ou d'authentification exigée pour l'outil tiers."
# }
# bullet_points = "\n".join([mapping_instructions[field] for field in missing_fields if field in mapping_instructions])
# return f"""
# ATTENTION : Les données suivantes sont obligatoires mais absentes.
# Vous devez formuler une question naturelle et polie pour demander à l'utilisateur de préciser :
# {bullet_points}
# """
}

View File

@@ -279,16 +279,22 @@ Votre objectif est de générer l'intégralité du code source d'un projet basé
---
### RÈGLE ABSOLUE DE TRANSPOSITION DU LANGAGE :
Tu dois impérativement identifier le langage cible défini dans la `ProjectSpec` (champ `"language"`). Tu as l'interdiction stricte d'utiliser un autre langage.
- **Extensions :** Adapte l'extension du point d'entrée et des tests (.rb pour Ruby, .py pour Python, .js pour JS, .go pour Go, etc.).
- **Gestionnaire de dépendances :** Utilise le fichier standard du langage (ex: `Gemfile` pour Ruby, `requirements.txt` ou `pyproject.toml` pour Python, `package.json` pour Node.js).
- **Commandes du README :** Les instructions d'installation et d'exécution doivent correspondre EXACTEMENT au langage (ex: `bundle install` et `ruby main.rb` pour Ruby).
### DIRECTIVES D'ARCHITECTURE (ADAPTATIVE) :
Tu dois appliquer STRICTEMENT l'une des deux structures suivantes selon des critères précis :
1. ARBORESCENCE "SIMPLE" (Pour les scripts uniques, outils CLI mono-fichier ou automatisations courtes) :
- RÈGLE : Tout le code métier tient dans un seul et unique fichier à la racine. Pas de sous-dossiers inutiles.
- À la racine : Le fichier de documentation (ex: README.md), le fichier de gestion des dépendances (ex: requirements.txt, package.json, Cargo.toml), le script unique (point d'entrée), et son fichier de test associé.
- À la racine : Le fichier de documentation (`README.md`), le fichier de dépendances adapté (ex: `Gemfile`, `requirements.txt`), le script unique (ex: `main.rb`, `main.py`), et le dossier de tests (ex: `/tests`, `/spec`).
2. ARBORESCENCE "COMPLEXE" (Obligatoire pour les API REST, applications Web, architectures modulaires ou multi-fichiers) :
- RÈGLE : Dès que le projet nécessite une séparation des responsabilités (ex: modèles, contrôleurs/routes, services) ou est une API, cette structure est MANDATAIRE.
- À la racine : Uniquement la documentation, la configuration globale (.env, .gitignore, etc.), le fichier de gestion des dépendances, et le POINT D'ENTRÉE PRINCIPAL de l'application (ex: main.py, index.js, server.ts, Program.cs).
- À la racine : Documentation, configurations globales, gestionnaire de dépendances, et le POINT D'ENTRÉE PRINCIPAL (ex: `main.rb`, `app.py`, `index.js`).
- En sous-dossiers :
- L'intégralité des modules internes, composants logiques, routes ou couches métiers doit être isolée dans un ou plusieurs sous-dossiers dédiés (ex: `/app`, `/src`, `/src/models`). Aucun autre fichier de code métier que le point d'entrée ne doit se trouver à la racine.
- Les tests unitaires et fonctionnels doivent être isolés dans un répertoire dédié à la racine (ex: `/tests`, `/specs`).
@@ -297,12 +303,14 @@ Tu dois appliquer STRICTEMENT l'une des deux structures suivantes selon des crit
### PARADIGMES ET QUALITÉ DE CODE (CLEAN CODE) :
1. PROGRAMMATION ORIENTÉE OBJET (POO) & MODULARITÉ :
1. ADAPTABILITÉ : Si le projet est un script simple (Arborescence Simple), n'applique pas de Design Patterns complexes ou de POO inutile si le langage favorise une approche procédurale ou fonctionnelle simple. Reste pragmatique.
2. PROGRAMMATION ORIENTÉE OBJET (POO) & MODULARITÉ :
- Tu dois privilégier une approche orientée objet. Utilise des **classes** pour modéliser les entités, les services et la logique métier.
- Favorise l'**encapsulation** (méthodes privées/protégées) pour protéger l'état interne des objets.
- Utilise l'**abstraction** pour définir des interfaces ou des classes de base lorsque la logique le permet, afin de faciliter l'extension.
2. PRINCIPES DE DESIGN (SOLID & DRY) :
3. PRINCIPES DE DESIGN (SOLID & DRY) :
- **Single Responsibility :** Chaque classe ou fonction ne doit avoir qu'une seule responsabilité.
- **DRY (Don't Repeat Yourself) :** Extrais la logique répétitive dans des fonctions ou des classes utilitaires.
- **Découplage :** Évite les dépendances trop fortes entre les modules pour permettre une évolution facile du code.
@@ -323,6 +331,14 @@ Tu dois appliquer STRICTEMENT l'une des deux structures suivantes selon des crit
- Respect strict des conventions de nommage et guides de style du langage ciblé (ex: PEP 8 pour Python, standard Ruby, etc.).
- Gestion d'erreurs exhaustive via des blocs try/catch/except ciblés et utilisation d'un module de Logging (pas de sorties consoles brutes ou de 'print' sans contexte).
4. DOCUMENTATION POUR LA QA (OBLIGATOIRE) :
- Tu dois obligatoirement générer un fichier 'README.md' à la racine.
- Ce fichier doit contenir :
- Une description claire du projet.
- Les instructions d'installation (ex: pip install -r requirements.txt).
- Les instructions de lancement (ex: python main.py).
- Une section "TESTING" expliquant précisément comment la QA peut vérifier que le code fonctionne (ex: commandes de tests, scénarios de test attendus).
---
### SÉCURITÉ ET VÉRIFICATION DU FORMAT DE SORTIE :

View File

@@ -10,12 +10,17 @@ setup_logging()
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[Startup] Initialisation automatique de Qdrant dans Docker...")
print("[Startup] Initialisation de Qdrant...")
# On crée l'instance UNE SEULE FOIS
qdrant_repo = QdrantRepository()
try:
await qdrant_repo.init_collection(vector_size=1024)
await qdrant_repo.init_collection(vector_size=1024)
app.state.qdrant_repo = qdrant_repo
print("[Startup] Qdrant prêt et attaché à l'application.")
except Exception as e:
print(f"[Startup] Erreur lors de l'initialisation de Qdrant : {e}")
print(f"[Startup] Erreur fatale lors de l'initialisation de Qdrant : {e}")
raise e
yield
print("[Shutdown] Fermeture propre de la connexion Qdrant...")

View File

@@ -1,11 +1,7 @@
from app.repositories.qdrant_repository import QdrantRepository
from app.services.embedding_service import build_embedding
qdrant_repository = QdrantRepository()
async def find_existing_project(user_input: str):
async def find_existing_project(qdrant_repo: QdrantRepository, user_input: str):
# query_vector = await build_embedding.get_mesh_embedding(user_input)
dummy_vector = [0.0] * 1024 # A modifier avec un vrai embedding plus tard TODO
return await qdrant_repository.search_similar_project(query_vector=dummy_vector)
dummy_vector = [0.0] * 1024
return await qdrant_repo.search_similar_project(query_vector=dummy_vector)

View File

@@ -1,11 +1,12 @@
from app.graph.workflow import compiled_graph
from app.repositories.qdrant_repository import QdrantRepository
async def run_arc_workflow(state_data: dict) -> dict:
async def run_arc_workflow(state_data: dict, qdrant_repo: QdrantRepository) -> dict:
"""
Prend le state actuel (provenant de l'API/Chainlit),
exécute le graphe jusqu'au prochain point d'arrêt (END),
et retourne le state mis à jour.
"""
final_state = await compiled_graph.ainvoke(state_data)
final_state = await compiled_graph.ainvoke(state_data, config={"configurable": {"qdrant_repo": qdrant_repo}})
return dict(final_state)

View File

@@ -0,0 +1,61 @@
services:
download-model:
image: alpine:latest
container_name: download-embedding-model
volumes:
- model_storage:/models
command: >
sh -c "if [ ! -f /models/snowflake-arctic-embed-m-v1.5-f16.gguf ]; then
wget --no-check-certificate 'https://huggingface.co/Snowflake/snowflake-arctic-embed-m-v1.5/resolve/main/gguf/snowflake-arctic-embed-m-v1.5-f16.gguf' -O /models/snowflake-arctic-embed-m-v1.5-f16.gguf;
fi"
networks:
- arc-network
embedding-server:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: embedding-arc
volumes:
- model_storage:/models
ports:
- "8002:8080"
command: "-m /models/snowflake-arctic-embed-m-v1.5-f16.gguf --embedding --host 0.0.0.0 --port 8080"
restart: unless-stopped
networks:
- arc-network
depends_on:
download-model:
condition: service_completed_successfully
download-gemma:
image: alpine:latest
container_name: download-gemma-model
volumes:
- model_storage:/models
command: >
sh -c "if [ ! -f /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf ]; then
wget --no-check-certificate 'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-UD-Q4_K_XL.gguf' -O /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf;
fi"
networks:
- arc-network
gemma-server:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: gemma-arc
volumes:
- model_storage:/models
ports:
- "8003:8080"
command: "-m /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf --host 0.0.0.0 --port 8080 -c 8192"
restart: unless-stopped
networks:
- arc-network
depends_on:
download-gemma:
condition: service_completed_successfully
volumes:
model_storage:
networks:
arc-network:
external: true

View File

@@ -0,0 +1,37 @@
services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant-arc
ports:
- "6333:6333"
- "6334:6334"
environment:
- QDRANT__TELEMETRY_DISABLED=true
volumes:
- qdrant_storage:/qdrant/storage
networks:
- arc-network
git-server:
image: gitea/gitea:latest
container_name: git-arc
ports:
- "3000:3000"
networks:
- arc-network
volumes:
- gitea_data:/data
environment:
- GITEA__security__INSTALL_LOCK=true
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea/gitea.db
- GITEA__server__ROOT_URL=http://localhost:3000/
- GITEA__server__SSH_PORT=22
volumes:
qdrant_storage:
gitea_data:
networks:
arc-network:
external: true

View File

@@ -1,82 +1,8 @@
services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant-arc
ports:
- "6333:6333"
- "6334:6334"
environment:
- QDRANT__TELEMETRY_DISABLED=true
volumes:
- qdrant_storage:/qdrant/storage
networks:
- arc-network
download-model:
image: alpine:latest
container_name: download-embedding-model
volumes:
- model_storage:/models
command: >
sh -c "
if [ ! -f /models/snowflake-arctic-embed-m-v1.5-f16.gguf ]; then
echo 'Téléchargement du modèle (Contournement SSL Proxy activé)...';
wget --no-check-certificate 'https://huggingface.co/Snowflake/snowflake-arctic-embed-m-v1.5/resolve/main/gguf/snowflake-arctic-embed-m-v1.5-f16.gguf' -O /models/snowflake-arctic-embed-m-v1.5-f16.gguf;
echo 'Téléchargement terminé avec succès !';
else
echo 'Le modèle est déjà présent.';
fi
"
embedding-server:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: embedding-arc
volumes:
- model_storage:/models
ports:
- "8002:8080"
command: "-m /models/snowflake-arctic-embed-m-v1.5-f16.gguf --embedding --host 0.0.0.0 --port 8080"
restart: unless-stopped
networks:
- arc-network
depends_on:
download-model:
condition: service_completed_successfully
download-gemma:
image: alpine:latest
container_name: download-gemma-model
volumes:
- model_storage:/models
command: >
sh -c "
if [ ! -f /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf ]; then
echo 'Téléchargement de Gemma 4 (Contournement SSL Proxy)...';
wget --no-check-certificate 'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-UD-Q4_K_XL.gguf' -O /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf;
echo 'Téléchargement de Gemma 4 terminé !';
else
echo 'Le modèle Gemma 4 est déjà présent.';
fi
"
gemma-server:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: gemma-arc
volumes:
- model_storage:/models
ports:
- "8003:8080"
command: "-m /models/gemma-4-E4B-it-UD-Q4_K_XL.gguf --host 0.0.0.0 --port 8080 -c 8192"
restart: unless-stopped
networks:
- arc-network
depends_on:
download-gemma:
condition: service_completed_successfully
app:
build: .
container_name: arc-app
image: arc-app
ports:
- "8000:8000"
- "8001:8001"
@@ -84,19 +10,14 @@ services:
- .:/workspace
environment:
- PYTHONPATH=/workspace
- QDRANT_URL=http://qdrant:6333
- QDRANT_URL=http://qdrant-arc:6333
- QDRANT_COLLECTION=arc_projects
- EMBEDDING_SERVER_URL=http://embedding-server:8080
depends_on:
- qdrant
- embedding-server
- EMBEDDING_SERVER_URL=http://embedding-arc:8080
- GITEA_API_URL=http://git-arc:3000/api/v1
- GITEA_TOKEN=${GITEA_TOKEN}
networks:
- arc-network
volumes:
qdrant_storage:
model_storage:
networks:
arc-network:
driver: bridge
external: true

184
orchestrator.py Normal file
View File

@@ -0,0 +1,184 @@
import os
import sys
import asyncio
import subprocess
import httpx
current_dir = os.path.dirname(os.path.abspath(__file__))
backend_dir = os.path.join(current_dir, "backend")
if not os.path.exists(backend_dir):
print(f"❌ Erreur : Le dossier '{backend_dir}' est introuvable.")
sys.exit(1)
os.chdir(backend_dir)
sys.path.append(".")
try:
from app.core.config import settings
except ImportError as e:
print(f"❌ Erreur d'importation des settings : {e}")
print(f"Vérifie que ton dossier 'app' est bien dans '{backend_dir}'")
sys.exit(1)
COMPOSE_DIR = "."
NETWORK_NAME = "arc-network"
TOKEN_FILE = ".gitea_token"
PROJECTS = {
"infra": "arc-infra",
"ai": "arc-ai",
"app": "arc-app"
}
def run_command(command, cwd=None):
"""Lance une commande système de manière synchrone."""
print(f"🚀 Exécution : {' '.join(command) if isinstance(command, list) else command}")
try:
subprocess.run(command, cwd=cwd, check=True, capture_output=False)
return True
except subprocess.CalledProcessError as e:
print(f"❌ Erreur : {e}")
return False
async def wait_for_gitea(local_api_url):
"""Attend que l'API HTTP de Gitea soit pleinement initialisée."""
public_ping_url = f"{local_api_url}/version"
max_retries = 20
print("⏳ Attente du démarrage complet de Gitea et des migrations de la base de données...")
for i in range(max_retries):
try:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(public_ping_url)
if resp.status_code == 200:
print("✅ Gitea est prêt et opérationnel.")
return True
except Exception:
pass
if i < max_retries - 1:
await asyncio.sleep(3)
raise Exception(f"Gitea n'a pas répondu sur {public_ping_url} après plusieurs tentatives.")
async def bootstrap_gitea_agent(settings_obj, local_api_url):
"""Crée un token d'accès d'application pour l'admin."""
if os.path.exists(TOKEN_FILE):
with open(TOKEN_FILE, "r") as f:
token = f.read().strip()
if token:
return token
token_url = f"{local_api_url}/users/{settings_obj.gitea_admin_user}/tokens"
async with httpx.AsyncClient(verify=False) as client:
resp = await client.post(
token_url,
auth=(settings_obj.gitea_admin_user, settings_obj.gitea_admin_password),
json={
"name": "dev-agent-token",
"scopes": ["all"]
}
)
if resp.status_code in [200, 201]:
token_data = resp.json()
token = token_data.get('token') or token_data.get('sha1')
if not token:
raise Exception(f"Format de réponse inconnu pour le token : {token_data}")
with open(TOKEN_FILE, "w") as f:
f.write(token)
return token
elif resp.status_code in [400, 409]:
print("⚠️ Le token existe déjà dans Gitea. Réinitialisation...")
subprocess.run([
"docker", "exec", "-u", "git", "git-arc",
"gitea", "admin", "token", "delete",
"--username", settings_obj.gitea_admin_user,
"--name", "dev-agent-token"
], capture_output=True)
return await bootstrap_gitea_agent(settings_obj, local_api_url)
else:
raise Exception(f"Échec création token : {resp.status_code} - {resp.text}")
async def main():
# 1. Réseau
print("--- ÉTAPE 1 : RÉSEAU ---")
create_net = subprocess.run(
["docker", "network", "create", NETWORK_NAME],
capture_output=True, text=True, shell=(os.name == 'nt')
)
if create_net.returncode == 0:
print(f"✅ Réseau '{NETWORK_NAME}' créé.")
else:
print(f"✅ Réseau '{NETWORK_NAME}' déjà présent ou géré.")
# 2. Infrastructure
print(f"\n--- ÉTAPE 2 : INFRASTRUCTURE (Projet: {PROJECTS['infra']}) ---")
infra_cmd = [
"docker", "compose",
"-p", PROJECTS["infra"],
"-f", f"{COMPOSE_DIR}/docker-compose-infra.yml",
"up", "-d"
]
if not run_command(infra_cmd):
sys.exit(1)
# 3. Provisioning Gitea
print("\n--- ÉTAPE 3 : PROVISIONING GITEA ---")
local_api_url = settings.gitea_api_url.replace("git-arc", "localhost")
# Étape critique : on attend d'abord que le service web et la DB soient OK
try:
await wait_for_gitea(local_api_url)
except Exception as e:
print(f"❌ Erreur critique de démarrage : {e}")
sys.exit(1)
print("👤 Création / Vérification du compte administrateur Gitea...")
result = subprocess.run([
"docker", "exec", "-u", "git", "git-arc",
"gitea", "admin", "user", "create",
"--username", settings.gitea_admin_user,
"--password", settings.gitea_admin_password,
"--email", "admin@arc.local",
"--admin"
], capture_output=True, text=True)
if result.returncode == 0:
print("👤 Compte administrateur créé avec succès.")
elif "user already exists" in result.stderr:
print("👤 Compte administrateur existant et valide.")
else:
print(f"⚠️ Note Gitea CLI : {result.stderr.strip()}")
try:
agent_token = await bootstrap_gitea_agent(settings, local_api_url)
os.environ["GITEA_TOKEN"] = agent_token
print("✅ Provisioning Gitea réussi, token généré.")
except Exception as e:
print(f"❌ Erreur critique de provisioning : {e}")
sys.exit(1)
# 4. IA
print(f"\n--- ÉTAPE 4 : IA (Projet: {PROJECTS['ai']}) ---")
ai_cmd = [
"docker", "compose",
"-p", PROJECTS["ai"],
"-f", f"{COMPOSE_DIR}/docker-compose-ai.yml",
"up", "-d"
]
if not run_command(ai_cmd):
sys.exit(1)
# 5. Application
print(f"\n--- ÉTAPE 5 : APPLICATION (Projet: {PROJECTS['app']}) ---")
app_cmd = [
"docker", "compose",
"-p", PROJECTS["app"],
"-f", f"{COMPOSE_DIR}/docker-compose.yml",
"up", "-d", "--build"
]
run_command(app_cmd)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n🛑 Arrêt demandé par l'utilisateur.")

50
stop_project.py Normal file
View File

@@ -0,0 +1,50 @@
import subprocess
import os
import sys
COMPOSE_DIR = "backend"
NETWORK_NAME = "arc-network"
PROJECTS = {
"infra": "arc-infra",
"ai": "arc-ai",
"app": "arc-app"
}
def run_command(command):
"""Exécute une commande système."""
print(f"🚀 Exécution : {' '.join(command) if isinstance(command, list) else command}")
is_windows = os.name == 'nt'
try:
subprocess.run(command, shell=is_windows, check=True)
return True
except subprocess.CalledProcessError:
return False
def main():
print("🛑 Démarrage de l'arrêt complet du projet...")
# 1. Arrêter l'Application
print(f"\n--- ARRÊT DE L'APPLICATION ({PROJECTS['app']}) ---")
run_command(["docker", "compose", "-p", PROJECTS["app"], "-f", f"{COMPOSE_DIR}/docker-compose.yml", "down"])
# 2. Arrêter l'IA
print(f"\n--- ARRÊT DE L'IA ({PROJECTS['ai']}) ---")
run_command(["docker", "compose", "-p", PROJECTS["ai"], "-f", f"{COMPOSE_DIR}/docker-compose-ai.yml", "down"])
# 3. Arrêter l'Infrastructure
print(f"\n--- ARRÊT DE L'INFRASTRUCTURE ({PROJECTS['infra']}) ---")
run_command(["docker", "compose", "-p", PROJECTS["infra"], "-f", f"{COMPOSE_DIR}/docker-compose-infra.yml", "down"])
# 4. Optionnel : Supprimer le réseau
print(f"\n--- NETTOYAGE DU RÉSEAU ---")
# On essaie de supprimer le réseau, si il est utilisé par d'autres conteneurs, Docker ne le supprimera pas.
run_command(["docker", "network", "rm", NETWORK_NAME])
print("\n✅ Tout est arrêté proprement.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n🛑 Arrêt forcé par l'utilisateur.")