Correction QA: Validation arborescence + qualité/sécurité, ajout logique de fin complète, suppression repo non conforme

This commit is contained in:
Chevallier
2026-07-15 14:00:59 +02:00
parent 2ded228202
commit 9d464fb261
13 changed files with 156656 additions and 230 deletions

View File

@@ -3,8 +3,8 @@ from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
import httpx
from app.core.config import settings
from app.schemas.qa_report import QAEvaluation
from app.llm.prompts import QA_AGENT_PROMPT
from app.schemas.qa_report import QAEvaluation, StructureEvaluation
from app.llm.prompts import QA_AGENT_PROMPT, QA_STRUCTURE_AGENT_PROMPT
from app.sandbox.qa_client import run_project_qa
logger = logging.getLogger(__name__)
@@ -15,8 +15,9 @@ async_client = httpx.AsyncClient(verify=False)
async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
"""
Agent QA :
Analyse le code généré à l'aide de l'API externe de la Sandbox (Semgrep uniquement)
et produit le rapport d'évaluation structuré final.
Analyse le code généré à l'aide de l'API externe de la Sandbox (Semgrep uniquement),
valide la structure minimale du projet à l'aide d'un second appel LLM,
puis produit le rapport d'évaluation fusionné.
"""
logger.info(f"[QA Agent] Début du processus d'audit statique pour le projet : {project_title}")
@@ -39,12 +40,12 @@ async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
http_async_client=async_client,
)
# 2. Exécution de l'analyse statique via le service Sandbox indépendant
# 1. Exécution de l'analyse statique via le service Sandbox indépendant
try:
repo_name = repo_url.rstrip("/").split("/")[-1]
logger.info(f"[QA Agent] Envoi du dépôt '{repo_name}' au conteneur d'analyse statique...")
raw_results = await run_project_qa(repo_name=repo_name)
raw_results = await run_project_qa(repo_url=repo_url)
logger.info(f"[QA Agent] 📊 Retour Sandbox Statis - Status : {'Succès' if raw_results.is_executable else 'Échec'}")
logger.info(f"[QA Agent] 📊 Retour Sandbox - Nombre d'alertes de sécurité/qualité (Semgrep): {len(raw_results.issues)}")
@@ -57,46 +58,84 @@ async def run_qa_agent(project_title: str, dev_output: dict) -> QAEvaluation:
technical_feedback=[f"L'API de la sandbox a échoué : {type(e).__name__}: {str(e)}"]
)
# 3. Génération de l'évaluation finale via LLM
structured_llm = llm.with_structured_output(QAEvaluation, strict=True)
messages = [
SystemMessage(content=QA_AGENT_PROMPT),
]
# Le prompt est nettoyé des mentions relatives à l'exécution de tests dynamiques
user_content = f"PROJET À AUDITER : {project_title}\n"
user_content += f"LIEN DU DÉPÔT : {repo_url}\n"
user_content += f"RÉSULTATS DE L'ANALYSE STATIQUE DU CODE (Semgrep) :\n"
user_content += f"{raw_results.model_dump_json(indent=2)}\n\n"
user_content += "Analyse ces failles, vulnérabilités et défauts de qualité de code, puis génère ton évaluation de sécurité au format structuré demandé."
messages.append(HumanMessage(content=user_content))
# 2. Premier appel LLM : Audit de sécurité (Semgrep)
qa_evaluation = None
try:
qa_evaluation = await structured_llm.ainvoke(messages)
logger.info(f"[QA Agent] 📝 Justification du LLM : {qa_evaluation.global_summary}")
logger.info("[QA Agent] Lancement de l'audit de sécurité et de qualité (Semgrep)...")
structured_security_llm = llm.with_structured_output(QAEvaluation, strict=True)
if not qa_evaluation:
logger.error("[QA Agent] Le LLM a renvoyé une réponse vide (None).")
return QAEvaluation(
is_complete_and_safe=False,
global_summary="Erreur de formatage de l'évaluation par l'IA.",
technical_feedback=["L'évaluation automatique n'a pas pu être structurée correctement."]
)
security_messages = [
SystemMessage(content=QA_AGENT_PROMPT),
HumanMessage(content=(
f"PROJET À AUDITER : {project_title}\n"
f"LIEN DU DÉPÔT : {repo_url}\n"
f"RÉSULTATS DE L'ANALYSE STATIQUE DU CODE (Semgrep) :\n"
f"{raw_results.model_dump_json(indent=2)}\n\n"
f"Analyse ces failles, vulnérabilités et défauts de qualité de code, puis génère ton évaluation de sécurité au format structuré demandé."
))
]
if qa_evaluation.is_complete_and_safe:
logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Analyse statique propre).")
else:
reason = "Erreur d'exécution ou non-conformité" if "exit_code" in str(qa_evaluation) else "Failles de sécurité"
logger.warning(f"[QA Agent] ❌ Projet '{project_title}' REJETÉ ({reason}).")
return qa_evaluation
qa_evaluation = await structured_security_llm.ainvoke(security_messages)
except Exception as e:
logger.error(f"[QA Agent] ❌ Échec critique lors de l'analyse LLM : {type(e).__name__}: {str(e)}")
return QAEvaluation(
logger.error(f"[QA Agent] ❌ Échec lors de l'audit de sécurité LLM : {type(e).__name__}: {str(e)}")
qa_evaluation = QAEvaluation(
is_complete_and_safe=False,
global_summary="Erreur interne de l'Agent QA lors de la génération du rapport LLM.",
technical_feedback=[f"Le LLM a crashé avec l'erreur : {type(e).__name__}."]
global_summary="Erreur lors de la génération du rapport de sécurité par le LLM.",
technical_feedback=[f"Le module de sécurité a échoué : {type(e).__name__}"]
)
# 3. Second appel LLM : Validation de la structure minimale (Arborescence)
structure_evaluation = None
try:
logger.info("[QA Agent] Lancement de l'audit de conformité de l'arborescence...")
structured_structure_llm = llm.with_structured_output(StructureEvaluation, strict=True)
repo_structure = raw_results.runtime.stdout or "Arborescence non détectée ou vide."
structure_messages = [
SystemMessage(content=QA_STRUCTURE_AGENT_PROMPT),
HumanMessage(content=(
f"PROJET À ANALYSER : {project_title}\n"
f"ARBORESCENCE DU RÉPERTOIRE CLONÉ :\n"
f"{repo_structure}\n\n"
f"Vérifie si la structure respecte les exigences minimales et fournis ton évaluation structurée."
))
]
structure_evaluation = await structured_structure_llm.ainvoke(structure_messages)
logger.info(f"[QA Agent] 📁 Structure détectée : {structure_evaluation.detected_language} | Valide : {structure_evaluation.is_valid}")
except Exception as e:
logger.error(f"[QA Agent] ❌ Échec lors de la validation de structure LLM : {type(e).__name__}: {str(e)}")
structure_evaluation = StructureEvaluation(
is_valid=False,
detected_language="Inconnu",
missing_elements=["Erreur d'analyse structurelle"],
feedback="Le module d'analyse d'arborescence a rencontré une erreur de traitement."
)
# 4. Fusion des résultats des deux audits
final_is_complete_and_safe = qa_evaluation.is_complete_and_safe and structure_evaluation.is_valid
final_feedback = list(qa_evaluation.technical_feedback)
if not structure_evaluation.is_valid:
for missing in structure_evaluation.missing_elements:
final_feedback.append(f"[Structure] Élément obligatoire manquant : {missing}")
final_summary = (
f"{qa_evaluation.global_summary}\n\n"
f"--- 📁 Validation de la Structure ({structure_evaluation.detected_language}) ---\n"
f"Statut : {'Conforme' if structure_evaluation.is_valid else 'Non conforme'}\n"
f"Feedback : {structure_evaluation.feedback}"
)
if final_is_complete_and_safe:
logger.info(f"[QA Agent] ✅ Projet '{project_title}' VALIDÉ (Sécurité & Structure OK).")
else:
logger.warning(f"[QA Agent] ❌ Projet '{project_title}' REJETÉ (Non-conformité détectée).")
return QAEvaluation(
is_complete_and_safe=final_is_complete_and_safe,
global_summary=final_summary,
technical_feedback=final_feedback
)

View File

@@ -437,3 +437,16 @@ Tu disposes des données brutes suivantes :
### Ton Style de Feedback :
Sois ultra-précis et technique. Ne dis pas "Il y a une erreur dans le code", dis plutôt : "La fonction X à la ligne Y lève une exception de type ValueError car la variable Z est passée à None". Traduis chaque erreur technique brute en une instruction claire et actionnable pour l'Agent Dev.
"""
QA_STRUCTURE_AGENT_PROMPT = """
Tu es un agent expert en architecture et standardisation logicielle.
Ton rôle est d'analyser l'arborescence de fichiers d'un dépôt Git et de valider si la structure respecte rigoureusement les standards minimaux de livraison.
Critères stricts d'un projet conforme :
1. DOCUMENTATION : Présence obligatoire d'un fichier de documentation (ex: README.md, README.txt).
2. DÉPENDANCES : Présence obligatoire d'un fichier de gestion des dépendances adapté au langage (ex: requirements.txt ou pyproject.toml pour Python, package.json pour Node, go.mod pour Go, cargo.toml pour Rust, etc.). Ce fichier doit exister à la racine, même s'il est vide.
3. POINT D'ENTRÉE : Un script principal ou fichier de démarrage cohérent (ex: main.py/app.py pour Python, index.js/server.js pour Node, main.go pour Go, etc.).
4. SUITE DE TESTS : Présence obligatoire d'un dossier dédié aux tests (généralement nommé 'tests' ou 'test') contenant au moins un script de test (ex: test_script.py, app.test.js, etc.).
IMPORTANT : Sois intransigeant sur ces quatre piliers. Si le fichier de dépendances est absent (même si le script n'a pas de dépendances externes) ou si le dossier 'tests' est manquant ou vide, le projet doit être déclaré NON conforme (is_valid = False).
"""

View File

@@ -3,7 +3,7 @@ FROM semgrep/semgrep:latest
USER root
RUN sed -i 's/https/http/g' /etc/apk/repositories && \
apk update && apk add --no-cache python3 py3-pip
apk update && apk add --no-cache python3 py3-pip git
RUN pip install --no-cache-dir \
--trusted-host pypi.org \
@@ -13,6 +13,10 @@ RUN pip install --no-cache-dir \
WORKDIR /app
# COPY r2c-security-audit.yaml /app/r2c-security-audit.yaml
COPY rules/ /app/rules/
RUN sed -i 's/adjust_for_docker()/pass/g' /usr/lib/python3.12/site-packages/semgrep/commands/scan.py
COPY main.py .
EXPOSE 8004

View File

@@ -1,85 +1,179 @@
import subprocess
import json
import time
from fastapi import FastAPI
import tempfile
import os
import logging
import traceback
from fastapi import FastAPI
app = FastAPI()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SEMGREP_TIMEOUT = 300.0
@app.post("/scan/{repo_name}")
async def scan_repository(repo_name: str):
repo_path = f"/src/{repo_name}"
issues_list = []
SEMGREP_TIMEOUT = 300.0
SEMGREP_RULES_DIR = "/app/rules/"
def generate_tree_string(path: str, max_depth: int = 3) -> str:
"""Génère une représentation visuelle de l'arborescence (exclut le bruit)."""
lines = []
exclude_dirs = {".git", "__pycache__", "node_modules", "venv", ".venv", "env"}
def _walk(current_path, depth):
if depth > max_depth:
return
try:
entries = sorted(os.listdir(current_path))
except Exception:
return
for entry in entries:
if entry in exclude_dirs or entry.startswith('.'):
continue
full_path = os.path.join(current_path, entry)
indent = " " * depth
if os.path.isdir(full_path):
lines.append(f"{indent}📁 {entry}/")
_walk(full_path, depth + 1)
else:
lines.append(f"{indent}📄 {entry}")
_walk(path, 0)
return "\n".join(lines)
@app.post("/scan")
async def scan_repository(repo_url: str):
start_time = time.time()
issues_list = []
try:
secure_command = f"ulimit -f 10240 && semgrep scan --config=p/r2c-security-audit --json --quiet {repo_path}"
# 1. CRÉATION DU DOSSIER TEMPORAIRE ET CLONAGE DU PROJET À SCANNER
with tempfile.TemporaryDirectory() as tmp_dir:
repo_path = os.path.join(tmp_dir, "repo")
logger.info(f"[Sandbox] Clonage dynamique du projet {repo_url} dans {repo_path}...")
result = subprocess.run(
secure_command,
shell=True,
capture_output=True,
text=True,
timeout=SEMGREP_TIMEOUT
)
clone_cmd = f"git clone --depth 1 {repo_url} {repo_path}"
clone_result = subprocess.run(clone_cmd, shell=True, capture_output=True, text=True)
duration = round(time.time() - start_time, 2)
exit_code = result.returncode
stderr_output = result.stderr
timeout_triggered = False
is_executable = True
if clone_result.returncode != 0:
logger.error(f"[Sandbox] Échec du git clone du projet : {clone_result.stderr}")
return {
"results": {
"is_executable": False,
"runtime": {
"exit_code": clone_result.returncode,
"stdout": "",
"stderr": f"Impossible de cloner le projet : {clone_result.stderr}",
"duration_seconds": round(time.time() - start_time, 2),
"timeout_triggered": False
},
"issues": []
}
}
except subprocess.TimeoutExpired as te:
logger.error(f"[Sandbox] Semgrep a dépassé le timeout sur {repo_name}")
duration = round(time.time() - start_time, 2)
exit_code = -1
stderr_output = f"L'analyse statique a été coupée : Timeout de {SEMGREP_TIMEOUT}s dépassé."
timeout_triggered = True
is_executable = False
repo_tree = generate_tree_string(repo_path)
try:
cloned_files = os.listdir(repo_path)
logger.info(f"[Sandbox] 📁 Fichiers récupérés après clone : {cloned_files}")
if not cloned_files or cloned_files == ['.git']:
logger.warning("[Sandbox] ⚠️ Le dossier cloné est vide (ou ne contient que .git) !")
except Exception as dir_err:
logger.error(f"[Sandbox] Impossible de lister le contenu du dossier cloné : {dir_err}")
# 2. EXÉCUTION DE SEMGREP AVEC LA RÈGLE UNIQUE
try:
if os.path.exists(SEMGREP_RULES_DIR):
logger.info(f"[Sandbox] Répertoire de règles détecté : {SEMGREP_RULES_DIR}")
else:
logger.error(f"[Sandbox] ❌ Répertoire de règles introuvable à l'emplacement : {SEMGREP_RULES_DIR}")
logger.info(f"[Sandbox] Lancement du scan Semgrep avec l'audit unique ({SEMGREP_RULES_DIR})...")
secure_command = f"semgrep scan --config={SEMGREP_RULES_DIR} --json --quiet ."
result = subprocess.run(
secure_command,
shell=True,
cwd=repo_path,
capture_output=True,
text=True,
timeout=SEMGREP_TIMEOUT
)
duration = round(time.time() - start_time, 2)
exit_code = result.returncode
stderr_output = result.stderr
timeout_triggered = False
except subprocess.TimeoutExpired:
logger.error("[Sandbox] Timeout Semgrep dépassé.")
return {
"results": {
"is_executable": False,
"runtime": {
"exit_code": -1,
"stdout": "",
"stderr": "L'analyse statique a dépassé le timeout global.",
"duration_seconds": round(time.time() - start_time, 2),
"timeout_triggered": True
},
"issues": []
}
}
# 3. PARSING DES RÉSULTATS
try:
if result.returncode == 0:
is_executable = True
stdout_msg = f"Scan statique réussi.\n\n[STRUCTURE DETECTEE] :\n{repo_tree}"
if result.stdout.strip():
scan_data = json.loads(result.stdout)
for item in scan_data.get("results", []):
issues_list.append({
"tool": "Semgrep",
"file": item.get("path", "").replace(f"{repo_path}/", ""),
"line": item.get("start", {}).get("line"),
"code": item.get("check_id"),
"severity": item.get("extra", {}).get("severity", "MEDIUM").upper(),
"message": item.get("extra", {}).get("message", "")
})
else:
is_executable = False
stdout_msg = f"Échec du moteur de scan Semgrep (Exit code {result.returncode})."
logger.error(f"[Sandbox] Semgrep a échoué ! Code: {result.returncode}\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}")
except Exception as e:
is_executable = False
stdout_msg = "Erreur lors du traitement des résultats du scan."
stderr_output = f"Erreur parsing JSON Semgrep: {str(e)}\n{result.stderr}"
return {
"results": {
"is_executable": is_executable,
"runtime": {
"exit_code": exit_code,
"stdout": stdout_msg,
"stderr": stderr_output,
"duration_seconds": duration,
"timeout_triggered": timeout_triggered
},
"issues": issues_list
}
}
except Exception as global_e:
error_trace = traceback.format_exc()
logger.error(f"[Sandbox] CRASH INTERNE CRITIQUE :\n{error_trace}")
return {
"results": {
"is_executable": is_executable,
"is_executable": False,
"runtime": {
"exit_code": exit_code,
"stdout": "",
"stderr": stderr_output,
"duration_seconds": duration,
"timeout_triggered": timeout_triggered
"exit_code": -99,
"stdout": "Crash critique du conteneur de la Sandbox.",
"stderr": f"Exception Python : {str(global_e)}\n\nTraceback complet :\n{error_trace}",
"duration_seconds": round(time.time() - start_time, 2),
"timeout_triggered": False
},
"issues": []
}
}
try:
if result.stdout.strip():
scan_data = json.loads(result.stdout)
for item in scan_data.get("results", []):
issues_list.append({
"tool": "Semgrep",
"file": item.get("path", "").replace(f"{repo_path}/", ""),
"line": item.get("start", {}).get("line"),
"code": item.get("check_id"),
"severity": item.get("extra", {}).get("severity", "MEDIUM").upper(),
"message": item.get("extra", {}).get("message", "")
})
is_executable = True
stderr_output = result.stderr
except Exception as e:
is_executable = False
stderr_output = f"Erreur parsing JSON Semgrep: {str(e)}\n{result.stderr}"
return {
"results": {
"is_executable": is_executable,
"runtime": {
"exit_code": exit_code,
"stdout": "Scan statique universel effectué avec succès.",
"stderr": stderr_output,
"duration_seconds": duration,
"timeout_triggered": timeout_triggered
},
"issues": issues_list
}
}

View File

@@ -4,23 +4,42 @@ from app.core.config import settings
from app.schemas.qa_report import QARawResults, RuntimeOutput
logger = logging.getLogger(__name__)
async_client = httpx.AsyncClient(verify=False)
async def run_project_qa(repo_name: str) -> QARawResults:
async def run_project_qa(repo_url: str) -> QARawResults:
"""
Sollicite l'API du conteneur persistant arc-sandbox pour effectuer
une analyse de qualité et sécurité statique (Semgrep).
Sollicite l'API du conteneur arc-sandbox pour effectuer
une analyse de qualité et sécurité statique (Semgrep) en clonant le dépôt.
"""
sandbox_url = f"http://arc-sandbox:8004/scan/{repo_name}"
logger.info(f"[QA Client] Envoi de la requête de scan à la sandbox : {sandbox_url}")
sandbox_url = "http://arc-sandbox:8004/scan"
logger.info(f"[QA Client] Envoi de la requête de scan pour le dépôt : {repo_url}")
try:
response = await async_client.post(sandbox_url, timeout=120.0)
response = await async_client.post(sandbox_url, params={"repo_url": repo_url}, timeout=150.0)
if response.status_code == 200:
data = response.json()
return QARawResults.model_validate(data.get("results"))
if data is None:
logger.error("[QA Client] ❌ La Sandbox a répondu 200 OK mais a renvoyé une réponse vide (null).")
return QARawResults(
is_executable=False,
runtime=RuntimeOutput(
exit_code=-1,
stdout="",
stderr="Erreur interne Sandbox : Réponse JSON vide.",
duration_seconds=0,
timeout_triggered=False
),
issues=[]
)
results = data.get("results", {})
if not results.get("is_executable"):
logger.error(f"[QA Client] ❌ Erreur interne Sandbox (Semgrep STDERR) : {results.get('runtime', {}).get('stderr')}")
return QARawResults.model_validate(results)
else:
logger.error(f"[QA Client] Erreur de la Sandbox (Status {response.status_code}): {response.text}")
return QARawResults(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -30,3 +30,10 @@ class QAEvaluation(BaseModel):
default_factory=list,
description="Instructions de correction ultra-précises destinées au Dev Agent"
)
class StructureEvaluation(BaseModel):
is_valid: bool = Field(description="True si le projet possède un README, un fichier de dépendance cohérent et un point d'entrée d'exécution.")
detected_language: str = Field(description="Le langage principal identifié d'après la structure (ex: Python, Node, Go, Rust...).")
missing_elements: list[str] = Field(description="Liste des fichiers obligatoires manquants (ex: ['README.md', 'requirements.txt']). Vide si tout est OK.")
feedback: str = Field(description="Courte explication des manquements ou félicitations sur la structure.")

View File

@@ -6,7 +6,166 @@ import json
import os
import io
# --- FONCTION DE RENDU DE L'UI SELON LE STATUT ---
# --- CONFIGURATION GITEA ---
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "ton_token_gitea_ici")
async def push_readme_update_to_gitea(repo_url: str, readme_content: str):
"""
Met à jour ou crée le fichier README.md directement sur Gitea via son API REST.
"""
if not repo_url:
return
import base64
clean_url = repo_url.replace(".git", "")
parts = clean_url.rstrip("/").split("/")
if len(parts) >= 2:
repo_name = parts[-1]
owner = parts[-2]
# API Gitea pour récupérer/modifier le contenu d'un fichier
gitea_file_url = f"http://git-arc:3000/api/v1/repos/{owner}/{repo_name}/contents/README.md"
headers = {
"Content-Type": "application/json"
}
if GITEA_TOKEN:
headers["Authorization"] = f"token {GITEA_TOKEN}"
async with httpx.AsyncClient() as client:
sha = None
# 1. On tente de récupérer le SHA du README actuel s'il existe (requis par Gitea pour les updates)
try:
get_resp = await client.get(gitea_file_url, headers=headers, timeout=5.0)
if get_resp.status_code == 200:
sha = get_resp.json().get("sha")
except Exception as e:
print(f"⚠️ Impossible de récupérer le README actuel (il n'existe peut-être pas) : {e}")
# 2. Encodage du contenu en base64 (requis par l'API Gitea)
encoded_content = base64.b64encode(readme_content.encode("utf-8")).decode("utf-8")
payload = {
"content": encoded_content,
"message": "docs: ajouter le statut de conformité QA au README",
"branch": "main" # ou "master" selon ta configuration par défaut
}
if sha:
payload["sha"] = sha # Requis si le fichier existe déjà
# 3. Écriture (PUT crée ou met à jour le fichier)
try:
put_resp = await client.put(gitea_file_url, headers=headers, json=payload, timeout=10.0)
if put_resp.status_code in [200, 201]:
print("📝 README.md mis à jour avec succès sur Gitea !")
else:
print(f"⚠️ Échec de la mise à jour du README sur Gitea : {put_resp.status_code} - {put_resp.text}")
except Exception as e:
print(f"❌ Erreur lors de l'écriture du README sur Gitea : {e}")
async def delete_gitea_repository(repo_url: str):
"""
Supprime le dépôt sur Gitea via son API REST en cas d'échec ou d'abandon.
"""
if not repo_url:
return
# Nettoyage de l'URL pour extraire le owner et le repo_name
clean_url = repo_url.replace(".git", "")
parts = clean_url.rstrip("/").split("/")
if len(parts) >= 2:
repo_name = parts[-1]
owner = parts[-2]
# URL interne au réseau Docker pour appeler l'API Gitea
gitea_api_url = f"http://git-arc:3000/api/v1/repos/{owner}/{repo_name}"
headers = {}
if GITEA_TOKEN:
headers["Authorization"] = f"token {GITEA_TOKEN}"
try:
async with httpx.AsyncClient() as client:
response = await client.delete(gitea_api_url, headers=headers, timeout=10.0)
if response.status_code == 204:
print(f"🗑️ Dépôt Gitea {owner}/{repo_name} supprimé avec succès.")
else:
print(f"⚠️ Échec de la suppression Gitea : {response.status_code} - {response.text}")
except Exception as e:
print(f"❌ Erreur lors de la suppression du dépôt Gitea : {e}")
def inject_conforme_badge(state: dict) -> dict:
"""
Injecte la mention 'Conforme' dans le README.md du projet
dans l'état de session (gère le format liste de dict ou dictionnaire plat).
"""
dev_data = state.get("generated_code", {}) or {}
raw_files = dev_data.get("files", {})
badge_text = "\n\n---\n🛡️ **Statut QA :** ✅ Conforme\n"
if isinstance(raw_files, list):
readme_found = False
for f in raw_files:
if isinstance(f, dict):
path = f.get("path") or f.get("filename") or f.get("name") or ""
if path.lower() in ["readme.md", "readme"]:
content = f.get("content") or f.get("code") or ""
if "✅ Conforme" not in content:
new_content = content + badge_text
if "content" in f:
f["content"] = new_content
elif "code" in f:
f["code"] = new_content
readme_found = True
break
if not readme_found:
raw_files.append({
"path": "README.md",
"content": "# Projet\n" + badge_text
})
elif isinstance(raw_files, dict):
readme_key = None
for k in raw_files.keys():
if k.lower() in ["readme.md", "readme"]:
readme_key = k
break
if readme_key:
content = raw_files[readme_key] or ""
if "✅ Conforme" not in content:
raw_files[readme_key] = content + badge_text
else:
raw_files["README.md"] = "# Projet" + badge_text
return state
async def reset_to_start(message_text: str = "Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel."):
"""
Réinitialise complètement l'état de la session utilisateur.
"""
initial_state = {
"user_input": "",
"user_feedback": None,
"chat_history": [],
"spec": {},
"status": "start",
"loop_count": 0,
"existing_project": None,
"generated_code": None,
"qa_result": None,
"is_completed": False
}
cl.user_session.set("graph_state", initial_state)
cl.user_session.set("current_files", {})
cl.user_session.set("side_code_viewer", None)
cl.user_session.set("files_message", None)
await cl.Message(content=message_text).send()
async def render_workflow_state(new_state: dict):
"""
Fonction centrale pour aiguiller l'affichage Chainlit
@@ -62,7 +221,6 @@ async def render_workflow_state(new_state: dict):
final_state = response.json()
cl.user_session.set("graph_state", final_state)
# Rappel de la fonction pour traiter le nouvel état de review
await render_workflow_state(final_state)
else:
@@ -74,114 +232,175 @@ async def render_workflow_state(new_state: dict):
).send()
elif current_status in ["wait_human_review", "approved_by_human"]:
dev_data = new_state.get("generated_code", {})
qa_res = new_state.get("qa_result", {})
raw_files = dev_data.get("files", {})
files = {}
if isinstance(raw_files, list):
for f in raw_files:
if isinstance(f, dict):
path = f.get("path") or f.get("filename") or f.get("name")
content = f.get("content") or f.get("code") or ""
if path:
files[path] = content
elif isinstance(raw_files, dict):
files = raw_files
is_safe = qa_res.get("is_complete_and_safe", False)
badge_qa = "✅ CONFORME" if is_safe else "⚠️ SÉCURITÉ/QUALITÉ À VÉRIFIER"
dev_data = new_state.get("generated_code", {}) or {}
qa_res = new_state.get("qa_result", {}) or {}
gitea_url = dev_data.get("repo_url", "")
# Correction de l'URL pour y accéder depuis ton navigateur (hors réseau Docker)
if gitea_url and "git-arc:3000" in gitea_url:
gitea_url = gitea_url.replace("git-arc:3000", "localhost:3000")
access_md = f"**Résultat global QA :** {badge_qa}\n\n"
access_md += "### 🔗 Accès au projet\n\n"
if gitea_url:
access_md += f"🔗 **Lien vers le dépôt Gitea :** [Accéder au dépôt]({gitea_url})\n\n"
is_safe = qa_res.get("is_complete_and_safe", False)
# 2. Gestion du bouton de téléchargement ZIP (Correction finale de la variable)
project_title = new_state.get("generated_code", {}).get("spec_title", "projet")
message_elements = []
# ==================== PARCOURS 1 : LE PROJET EST CONFORME ====================
if is_safe:
raw_files = dev_data.get("files", {})
files = {}
if isinstance(raw_files, list):
for f in raw_files:
if isinstance(f, dict):
path = f.get("path") or f.get("filename") or f.get("name")
content = f.get("content") or f.get("code") or ""
if path:
files[path] = content
elif isinstance(raw_files, dict):
files = raw_files
if files:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for filepath, file_content in files.items():
zip_file.writestr(filepath, file_content)
zip_bytes = zip_buffer.getvalue()
access_md = "**Résultat global QA :** ✅ CONFORME\n\n"
access_md += "### 🔗 Accès au projet\n\n"
if gitea_url:
access_md += f"🔗 **Lien vers le dépôt Gitea :** [Accéder au dépôt]({gitea_url})\n\n"
message_elements.append(
cl.File(name=f"{project_title}.zip", content=zip_bytes, display="inline")
)
else:
access_md += "⚠️ Aucun fichier source trouvé pour générer le ZIP.\n"
project_title = dev_data.get("spec_title", "projet")
message_elements = []
await cl.Message(content=access_md, elements=message_elements).send()
if files:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for filepath, file_content in files.items():
zip_file.writestr(filepath, file_content)
zip_bytes = zip_buffer.getvalue()
# 3. Code Source Produit (Regroupé dans un seul message avec ses actions)
cl.user_session.set("current_files", files)
file_actions = []
for filename in files.keys():
file_actions.append(
cl.Action(
name="open_file",
payload={"filename": filename},
label=f"📄 {filename}"
message_elements.append(
cl.File(name=f"{project_title}.zip", content=zip_bytes, display="inline")
)
else:
access_md += "⚠️ Aucun fichier source trouvé pour générer le ZIP.\n"
await cl.Message(content=access_md, elements=message_elements).send()
cl.user_session.set("current_files", files)
file_actions = []
for filename in files.keys():
file_actions.append(
cl.Action(
name="open_file",
payload={"filename": filename},
label=f"📄 {filename}"
)
)
files_message = cl.Message(
content="### 📂 Code Source Produit\n\nClique sur un fichier pour l'afficher :",
actions=file_actions
)
await files_message.send()
cl.user_session.set("files_message", files_message)
files_message = cl.Message(
content="### 📂 Code Source Produit\n\nClique sur un fichier pour l'afficher :",
actions=file_actions
)
await files_message.send()
cl.user_session.set("files_message", files_message)
# 4. Demande de validation (Boutons Valider / Refuser)
res = await cl.AskActionMessage(
content="**Souhaitez-vous valider et packager ce livrable ?**",
actions=[
cl.Action(name="valider_projet", payload={"value": "approve"}, label="✅ Valider & Livrer", description="Génère l'archive et indexe le projet"),
cl.Action(name="refuser_projet", payload={"value": "refuse"}, label="❌ Refuser et corriger", description="Renvoie le projet au PM avec vos commentaires")
],
timeout=3600
).send()
if res and res.get("name") == "valider_projet":
await cl.Message(content="🎉 **Projet validé.**").send()
new_state["status"] = "human_approved"
cl.user_session.set("graph_state", new_state)
async with httpx.AsyncClient() as client:
resp = await client.post("http://127.0.0.1:8000/api/workflow/run", json=new_state, timeout=600.0)
final_state = resp.json()
cl.user_session.set("graph_state", final_state)
elif res and res.get("name") == "refuser_projet":
feedback_user = await cl.AskUserMessage(
content="Veuillez décrire les corrections ou les modifications à apporter au projet.",
timeout=600
res = await cl.AskActionMessage(
content="**Souhaitez-vous valider et packager ce livrable ?**",
actions=[
cl.Action(name="valider_projet", payload={"value": "approve"}, label="✅ Valider & Livrer", description="Ajoute le statut conforme au README et livre l'archive"),
cl.Action(name="refuser_projet", payload={"value": "refuse"}, label="❌ Refuser et corriger", description="Renvoie le projet au PM avec tes commentaires")
],
timeout=3600
).send()
if feedback_user:
new_state["status"] = "human_refused"
new_state["user_feedback"] = feedback_user["output"]
cl.user_session.set("graph_state", new_state)
if res and res.get("name") == "valider_projet":
await cl.Message(content="🎉 **Projet validé et marqué comme conforme !**").send()
await cl.Message(content="🔄 **Feedback transmis.** Prise en compte des modifications...").send()
new_state = inject_conforme_badge(new_state)
readme_content = "# Projet\n\n---\n🛡️ **Statut QA :** ✅ Conforme\n"
dev_data = new_state.get("generated_code", {}) or {}
raw_files = dev_data.get("files", {})
if isinstance(raw_files, list):
for f in raw_files:
if isinstance(f, dict):
path = f.get("path") or f.get("filename") or f.get("name") or ""
if path.lower() in ["readme.md", "readme"]:
readme_content = f.get("content") or f.get("code") or ""
break
elif isinstance(raw_files, dict):
for k, v in raw_files.items():
if k.lower() in ["readme.md", "readme"]:
readme_content = v
break
await push_readme_update_to_gitea(gitea_url, readme_content)
new_state["status"] = "human_approved"
cl.user_session.set("graph_state", new_state)
async with httpx.AsyncClient() as client:
resp = await client.post("http://127.0.0.1:8000/api/workflow/run", json=new_state, timeout=600.0)
loop_state = resp.json()
cl.user_session.set("graph_state", loop_state)
final_state = resp.json()
cl.user_session.set("graph_state", final_state)
await render_workflow_state(loop_state)
elif res and res.get("name") == "refuser_projet":
feedback_user = await cl.AskUserMessage(
content="Veuillez décrire les corrections ou les modifications à apporter au projet.",
timeout=600
).send()
if feedback_user:
new_state["status"] = "human_refused"
new_state["user_feedback"] = feedback_user["output"]
cl.user_session.set("graph_state", new_state)
await cl.Message(content="🔄 **Feedback transmis.** Prise en compte des modifications...").send()
async with httpx.AsyncClient() as client:
resp = await client.post("http://127.0.0.1:8000/api/workflow/run", json=new_state, timeout=600.0)
loop_state = resp.json()
cl.user_session.set("graph_state", loop_state)
await render_workflow_state(loop_state)
# ==================== PARCOURS 2 : LE PROJET EST NON CONFORME ====================
else:
qa_feedback = qa_res.get("feedback", "L'agent QA a détecté des anomalies majeures sans description.")
non_compliant_md = (
"🚨 **Projet Non Conforme**\n\n"
"Malheureusement, le code produit par ARC n'a pas passé les critères de sécurité ou de qualité requis.\n\n"
f"### 📋 Rapport d'audit QA :\n"
f"> {qa_feedback}\n\n"
"Pour des raisons de sécurité et de qualité, le projet ne peut pas être livré en l'état.\n\n"
)
await cl.Message(content=non_compliant_md).send()
res = await cl.AskActionMessage(
content="**Que souhaitez-vous faire ?**",
actions=[
cl.Action(name="recommencer_projet", payload={"value": "restart"}, label="🔄 Recommencer de zéro", description="Supprime le dépôt et réinitialise l'analyse"),
cl.Action(name="abandonner_projet", payload={"value": "abort"}, label="🗑️ Abandonner & Quitter", description="Supprime le dépôt et réinitialise l'application")
],
timeout=3600
).send()
if res and res.get("name") in ["recommencer_projet", "abandonner_projet"]:
await cl.Message(content="🗑️ **Nettoyage en cours... Suppression du dépôt Gitea...**").send()
# Suppression du dépôt distant
await delete_gitea_repository(gitea_url)
if res.get("name") == "recommencer_projet":
reset_msg = (
"🔄 **Ancien projet supprimé avec succès.**\n\n"
"Faisons table rase ! Décris-moi ton besoin logiciel pour repartir sur de nouvelles bases : 👇"
)
await reset_to_start(reset_msg)
else:
reset_msg = (
"❌ **Session fermée et dépôt nettoyé.**\n\n"
"Si tu as un nouveau besoin à soumettre plus tard, envoie simplement un message pour démarrer."
)
await reset_to_start(reset_msg)
elif current_status == "delivered":
await cl.Message(content="✅ Ce projet a déjà été traité et livré.").send()
@@ -222,22 +441,10 @@ async def on_open_file(action: cl.Action):
await new_viewer.send(for_id=message_id)
cl.user_session.set("side_code_viewer", new_viewer)
@cl.on_chat_start
async def on_chat_start():
initial_state = {
"user_input": "",
"user_feedback": None,
"chat_history": [],
"spec": {},
"status": "start",
"loop_count": 0,
"existing_project": None,
"generated_code": None,
"qa_result": None,
"is_completed": False
}
cl.user_session.set("graph_state", initial_state)
await cl.Message(content="Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel.").send()
await reset_to_start()
@cl.on_message
@@ -265,5 +472,4 @@ async def on_message(message: cl.Message):
new_state = response.json()
cl.user_session.set("graph_state", new_state)
# Appel du rendu
await render_workflow_state(new_state)

View File

@@ -7,8 +7,6 @@ services:
container_name: arc-sandbox
ports:
- "8004:8004"
volumes:
- ./temp_repos:/src:rw
networks:
- arc-network