206 lines
9.4 KiB
Python
206 lines
9.4 KiB
Python
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
|
|
from app.schemas.code_output import ProjectCodeOutput, GeneratedFile
|
|
from app.llm.prompts import DEV_AGENT_PROMPT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Clients HTTPX configurés pour ignorer les blocages de certificats/révocation du lab
|
|
sync_client = httpx.Client(verify=False)
|
|
async_client = httpx.AsyncClient(verify=False)
|
|
|
|
async def run_dev_agent(spec: dict, qa_feedback: list = None, repo_url: str = None, files: list = None) -> dict:
|
|
"""
|
|
Agent Dev : prend un état/spec validé, génère l'arborescence et le code,
|
|
et valide techniquement la sortie avant de la transmettre à la QA.
|
|
S'appuie sur repo_url si on est dans une boucle de correction.
|
|
"""
|
|
logger.info(f"[Dev Agent] Début de la génération pour : {spec.get('title', 'Sans titre')}")
|
|
|
|
llm = ChatOpenAI(
|
|
base_url=settings.llm_base_url,
|
|
api_key=settings.llm_api_key,
|
|
model=settings.llm_model_dev,
|
|
temperature=0.2,
|
|
max_retries=2,
|
|
http_client=sync_client,
|
|
http_async_client=async_client,
|
|
model_kwargs={"response_format": {"type": "json_object"}}
|
|
)
|
|
structured_llm = llm.with_structured_output(ProjectCodeOutput, strict=True)
|
|
|
|
messages = [
|
|
SystemMessage(content=DEV_AGENT_PROMPT),
|
|
]
|
|
|
|
user_content = f"CAHIER DES CHARGES (ProjectSpec) :\n{json.dumps(spec, indent=2, ensure_ascii=False)}\n\n"
|
|
if files:
|
|
user_content += f"💻 CODE ACTUEL (Version précédente à corriger) :\n{json.dumps(files, indent=2, ensure_ascii=False)}\n\n"
|
|
if qa_feedback:
|
|
user_content += f"⚠️ RETOURS DE VALIDATION QA (Corrections à appliquer impérativement) :\n{json.dumps(qa_feedback, indent=2, ensure_ascii=False)}\n\n"
|
|
|
|
user_content += "Génère maintenant le JSON complet contenant l'arborescence ('tree') et tous les fichiers ('files') décrits."
|
|
messages.append(HumanMessage(content=user_content))
|
|
|
|
try:
|
|
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"), repo_url=repo_url)
|
|
|
|
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, repo_url: str = None) -> 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.
|
|
"""
|
|
if repo_url:
|
|
repo_name = repo_url.rstrip("/").split("/")[-1]
|
|
logger.info(f"[Gitea] Mode correction : réutilisation du dépôt existant '{repo_name}'")
|
|
return repo_name
|
|
|
|
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:
|
|
break
|
|
elif response.status_code == 200:
|
|
repo_name = f"{base_name}_{counter}"
|
|
counter += 1
|
|
else:
|
|
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, repo_url: str = None) -> 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 == "_")
|
|
files_list = [f.model_dump() for f in code_data.files]
|
|
|
|
try:
|
|
repo_name = await _ensure_gitea_repo(base_name, repo_url=repo_url)
|
|
except Exception as e:
|
|
return {
|
|
"status": "partial_success_git_failed",
|
|
"error": str(e),
|
|
"spec_title": code_data.spec_title,
|
|
"tree": code_data.tree,
|
|
"files": files_list
|
|
}
|
|
|
|
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,
|
|
"files": files_list
|
|
}
|
|
|
|
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,
|
|
"files": files_list
|
|
}
|
|
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)")
|
|
title = spec.get("title", "automation_script")
|
|
|
|
fallback = ProjectCodeOutput(
|
|
spec_title=title,
|
|
tree=["main.py", "README.md", "requirements.txt"],
|
|
files=[
|
|
GeneratedFile(path="main.py", content="import logging\nlogging.basicConfig(level=logging.INFO)\n\ndef main():\n logging.error('Le Dev Agent a rencontré une erreur de génération.')\n\nif __name__ == '__main__':\n main()"),
|
|
GeneratedFile(path="README.md", content=f"# {title}\nGénération en mode fallback suite à une erreur technique."),
|
|
GeneratedFile(path="requirements.txt", content="# Aucune dépendance externe définie (Fallback)\n")
|
|
]
|
|
)
|
|
return fallback.model_dump() |