From 17e887b2681ff097f3132231b4648fac5ec8718f Mon Sep 17 00:00:00 2001 From: Chevallier Date: Mon, 22 Jun 2026 15:55:59 +0200 Subject: [PATCH] =?UTF-8?q?fin=20=C3=A9tape=202=20(dev=5Fagent)=20+=20auto?= =?UTF-8?q?matisation=20lancement=20projet/arr=C3=AAt=20projet=20+=20s?= =?UTF-8?q?=C3=A9paration=20des=20docker-compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- README.md | 140 +++++++++------- backend/Dockerfile | 1 + backend/README.md | 15 +- backend/app/agents/dev_agent.py | 137 +++++++++++++++- backend/app/api/routes/workflow.py | 8 +- backend/app/core/config.py | 10 ++ backend/app/graph/nodes.py | 53 ++----- backend/app/llm/prompts.py | 24 ++- backend/app/main.py | 11 +- backend/app/services/retrieval_service.py | 10 +- backend/app/services/workflow_service.py | 5 +- backend/docker-compose-ai.yml | 61 +++++++ backend/docker-compose-infra.yml | 37 +++++ backend/docker-compose.yml | 91 +---------- orchestrator.py | 184 ++++++++++++++++++++++ ressources/{test.md => Planning.md} | 0 stop_project.py | 50 ++++++ 18 files changed, 633 insertions(+), 212 deletions(-) create mode 100644 backend/docker-compose-ai.yml create mode 100644 backend/docker-compose-infra.yml create mode 100644 orchestrator.py rename ressources/{test.md => Planning.md} (100%) create mode 100644 stop_project.py diff --git a/.gitignore b/.gitignore index 050fbba..c63984e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,10 @@ dist/ *.egg-info/ # Jupyter -.ipynb_checkpoints/ \ No newline at end of file +.ipynb_checkpoints/ + +# Token files +.gitea_token + +# Repo temp +temp_repos/ \ No newline at end of file diff --git a/README.md b/README.md index 8c7dba3..c556a7d 100644 --- a/README.md +++ b/README.md @@ -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 d’appel +│ │ │ ├── prompts.py # prompts centralisés +│ │ │ └── providers.py # Gemma/llama.cpp.... +│ │ │ +│ │ ├── models/ # modèles métier / persistance (métadonnées d’un 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 d’un 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 d’appel -│ │ ├── 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 ``` \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 5025d63..6f8e6e4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . . diff --git a/backend/README.md b/backend/README.md index a51b031..e98e8fb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/agents/dev_agent.py b/backend/app/agents/dev_agent.py index 86da974..058c4ff 100644 --- a/backend/app/agents/dev_agent.py +++ b/backend/app/agents/dev_agent.py @@ -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)") diff --git a/backend/app/api/routes/workflow.py b/backend/app/api/routes/workflow.py index 9a6e8a3..f14882d 100644 --- a/backend/app/api/routes/workflow.py +++ b/backend/app/api/routes/workflow.py @@ -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) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3680c8e..e9ef307 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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() \ No newline at end of file diff --git a/backend/app/graph/nodes.py b/backend/app/graph/nodes.py index d9013cc..7a9a90e 100644 --- a/backend/app/graph/nodes.py +++ b/backend/app/graph/nodes.py @@ -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} -# """ \ No newline at end of file + } \ No newline at end of file diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 7e1e293..3f535e1 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -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 : diff --git a/backend/app/main.py b/backend/app/main.py index 9c50627..8c705f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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...") diff --git a/backend/app/services/retrieval_service.py b/backend/app/services/retrieval_service.py index dfe418e..38c3e1e 100644 --- a/backend/app/services/retrieval_service.py +++ b/backend/app/services/retrieval_service.py @@ -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) \ No newline at end of file + dummy_vector = [0.0] * 1024 + return await qdrant_repo.search_similar_project(query_vector=dummy_vector) \ No newline at end of file diff --git a/backend/app/services/workflow_service.py b/backend/app/services/workflow_service.py index b32115c..427421d 100644 --- a/backend/app/services/workflow_service.py +++ b/backend/app/services/workflow_service.py @@ -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) \ No newline at end of file diff --git a/backend/docker-compose-ai.yml b/backend/docker-compose-ai.yml new file mode 100644 index 0000000..2aa4bd9 --- /dev/null +++ b/backend/docker-compose-ai.yml @@ -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 \ No newline at end of file diff --git a/backend/docker-compose-infra.yml b/backend/docker-compose-infra.yml new file mode 100644 index 0000000..302ecdc --- /dev/null +++ b/backend/docker-compose-infra.yml @@ -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 \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index bb7ec4a..8edc8e8 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -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 \ No newline at end of file + external: true \ No newline at end of file diff --git a/orchestrator.py b/orchestrator.py new file mode 100644 index 0000000..4981afe --- /dev/null +++ b/orchestrator.py @@ -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.") \ No newline at end of file diff --git a/ressources/test.md b/ressources/Planning.md similarity index 100% rename from ressources/test.md rename to ressources/Planning.md diff --git a/stop_project.py b/stop_project.py new file mode 100644 index 0000000..4bdd7aa --- /dev/null +++ b/stop_project.py @@ -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.") \ No newline at end of file