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

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)